mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-17 10:22:36 +00:00
No realtime public view sharing
This commit is contained in:
parent
7595377e34
commit
133fceb6cf
45 changed files with 2038 additions and 333 deletions
backend
src/baserow
contrib/database
api
export
fields
table
views
core
test_utils/fixtures
tests/baserow/contrib/database
web-frontend
locales
modules
test
fixtures
unit/database
3
backend/src/baserow/contrib/database/api/constants.py
Normal file
3
backend/src/baserow/contrib/database/api/constants.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
# When entities are exposed publicly to anonymous users as many entity ids are hardcoded
|
||||
# to this value as possible to prevent data leaks.
|
||||
PUBLIC_PLACEHOLDER_ENTITY_ID = 0
|
|
@ -47,7 +47,7 @@ def get_row_serializer_class(
|
|||
included in 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
|
||||
:type field_ids: Optional[Iterable[int]]
|
||||
:param field_names_to_include: If provided only the field names in the list will be
|
||||
included in the serializer. By default all the fields of the model are going
|
||||
to be included. Note that the field name must exist in the model in
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from baserow.contrib.database.views.models import GridViewFieldOptions
|
||||
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
|
||||
from baserow.contrib.database.api.fields.serializers import FieldSerializer
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.contrib.database.views.models import GridViewFieldOptions, ViewSort, View
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
|
||||
|
||||
class GridViewFieldOptionsSerializer(serializers.ModelSerializer):
|
||||
|
@ -23,3 +29,101 @@ class GridViewFilterSerializer(serializers.Serializer):
|
|||
child=serializers.IntegerField(),
|
||||
help_text="Only rows related to the provided ids are added to the response.",
|
||||
)
|
||||
|
||||
|
||||
class PublicViewSortSerializer(serializers.ModelSerializer):
|
||||
view = serializers.SlugField(source="view.slug")
|
||||
|
||||
class Meta:
|
||||
model = ViewSort
|
||||
fields = ("id", "view", "field", "order")
|
||||
extra_kwargs = {"id": {"read_only": True}}
|
||||
|
||||
|
||||
class PublicGridViewTableSerializer(serializers.Serializer):
|
||||
id = serializers.SerializerMethodField()
|
||||
database_id = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def get_id(self, instance):
|
||||
return PUBLIC_PLACEHOLDER_ENTITY_ID
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def get_database_id(self, instance):
|
||||
return PUBLIC_PLACEHOLDER_ENTITY_ID
|
||||
|
||||
|
||||
class PublicGridViewSerializer(serializers.ModelSerializer):
|
||||
id = serializers.SlugField(source="slug")
|
||||
table = PublicGridViewTableSerializer()
|
||||
type = serializers.SerializerMethodField()
|
||||
sortings = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(PublicViewSortSerializer(many=True))
|
||||
def get_sortings(self, instance):
|
||||
sortings = PublicViewSortSerializer(
|
||||
instance=instance.viewsort_set.filter(field__in=self.context["fields"]),
|
||||
many=True,
|
||||
)
|
||||
return sortings.data
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_type(self, instance):
|
||||
# It could be that the view related to the instance is already in the context
|
||||
# else we can call the specific_class property to find it.
|
||||
view = self.context.get("instance_type")
|
||||
if not view:
|
||||
view = view_type_registry.get_by_model(instance.specific_class)
|
||||
|
||||
return view.type
|
||||
|
||||
class Meta:
|
||||
model = View
|
||||
fields = (
|
||||
"id",
|
||||
"table",
|
||||
"name",
|
||||
"order",
|
||||
"type",
|
||||
"sortings",
|
||||
"public",
|
||||
"slug",
|
||||
)
|
||||
extra_kwargs = {
|
||||
"id": {"read_only": True},
|
||||
"slug": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
class PublicFieldSerializer(FieldSerializer):
|
||||
table_id = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(OpenApiTypes.INT)
|
||||
def get_table_id(self, instance):
|
||||
return PUBLIC_PLACEHOLDER_ENTITY_ID
|
||||
|
||||
|
||||
class PublicGridViewInfoSerializer(serializers.Serializer):
|
||||
# get_fields is an actual method on serializers.Serializer so we can't override it.
|
||||
fields = serializers.SerializerMethodField(method_name="get_public_fields")
|
||||
view = serializers.SerializerMethodField()
|
||||
|
||||
# @TODO show correct API docs discriminated by field type.
|
||||
@extend_schema_field(PublicFieldSerializer(many=True))
|
||||
def get_public_fields(self, instance):
|
||||
return [
|
||||
field_type_registry.get_serializer(field, PublicFieldSerializer).data
|
||||
for field in self.context["fields"]
|
||||
]
|
||||
|
||||
# @TODO show correct API docs discriminated by field type.
|
||||
@extend_schema_field(PublicGridViewSerializer)
|
||||
def get_view(self, instance):
|
||||
return view_type_registry.get_serializer(
|
||||
instance, PublicGridViewSerializer, context=self.context
|
||||
).data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["context"] = kwargs.get("context", {})
|
||||
kwargs["context"]["fields"] = kwargs.pop("fields", [])
|
||||
super().__init__(instance=kwargs.pop("view", None), *args, **kwargs)
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
from django.urls import re_path
|
||||
|
||||
from .views import GridViewView
|
||||
|
||||
from .views import GridViewView, PublicGridViewInfoView, PublicGridViewRowsView
|
||||
|
||||
app_name = "baserow.contrib.database.api.views.grid"
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r"(?P<view_id>[0-9]+)/$", GridViewView.as_view(), name="list"),
|
||||
re_path(
|
||||
r"(?P<slug>[-\w]+)/public/info/$",
|
||||
PublicGridViewInfoView.as_view(),
|
||||
name="public_info",
|
||||
),
|
||||
re_path(
|
||||
r"(?P<slug>[-\w]+)/public/rows/$",
|
||||
PublicGridViewRowsView.as_view(),
|
||||
name="public_rows",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from django.db import transaction
|
||||
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
|
@ -18,10 +19,14 @@ from baserow.contrib.database.api.rows.serializers import (
|
|||
get_row_serializer_class,
|
||||
RowSerializer,
|
||||
)
|
||||
from baserow.contrib.database.api.views.errors import ERROR_VIEW_DOES_NOT_EXIST
|
||||
from baserow.contrib.database.api.views.grid.serializers import (
|
||||
GridViewFieldOptionsSerializer,
|
||||
PublicGridViewInfoSerializer,
|
||||
)
|
||||
from baserow.contrib.database.api.views.serializers import (
|
||||
FieldOptionsField,
|
||||
)
|
||||
from baserow.contrib.database.api.views.serializers import FieldOptionsField
|
||||
from baserow.contrib.database.rows.registries import row_metadata_registry
|
||||
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
|
@ -259,3 +264,215 @@ class GridViewView(APIView):
|
|||
)
|
||||
serializer = serializer_class(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class PublicGridViewRowsView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Returns only rows that belong to the related view's "
|
||||
"table.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="count",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.NONE,
|
||||
description="If provided only the count will be returned.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="include",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description=(
|
||||
"A comma separated list allowing the values of "
|
||||
"`field_options` which will add the object/objects with the "
|
||||
"same "
|
||||
"name to the response if included. The `field_options` object "
|
||||
"contains user defined view settings for each field. For "
|
||||
"example the field's width is included in here."
|
||||
),
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Defines how many rows should be returned.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Can only be used in combination with the `limit` "
|
||||
"parameter and defines from which offset the rows should "
|
||||
"be returned.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Defines which page of rows should be returned. Either "
|
||||
"the `page` or `limit` can be provided, not both.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="size",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Can only be used in combination with the `page` parameter "
|
||||
"and defines how many rows should be returned.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="search",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="If provided only rows with data that matches the search "
|
||||
"query are going to be returned.",
|
||||
),
|
||||
],
|
||||
tags=["Database table grid view"],
|
||||
operation_id="public_list_database_table_grid_view_rows",
|
||||
description=(
|
||||
"Lists the requested rows of the view's table related to the provided "
|
||||
"`slug` if the grid view is public."
|
||||
"The response is paginated either by a limit/offset or page/size style. "
|
||||
"The style depends on the provided GET parameters. The properties of the "
|
||||
"returned rows depends on which fields the table has. For a complete "
|
||||
"overview of fields use the **list_database_table_fields** endpoint to "
|
||||
"list them all. In the example all field types are listed, but normally "
|
||||
"the number in field_{id} key is going to be the id of the field. "
|
||||
"The value is what the user has provided and the format of it depends on "
|
||||
"the fields type.\n"
|
||||
"\n"
|
||||
),
|
||||
responses={
|
||||
200: get_example_pagination_serializer_class(
|
||||
get_example_row_serializer_class(add_id=True, user_field_names=False),
|
||||
additional_fields={
|
||||
"field_options": FieldOptionsField(
|
||||
serializer_class=GridViewFieldOptionsSerializer, required=False
|
||||
),
|
||||
},
|
||||
serializer_name="PublicPaginationSerializerWithGridViewFieldOptions",
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(["ERROR_GRID_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
|
||||
ViewDoesNotExist: ERROR_GRID_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
@allowed_includes("field_options")
|
||||
def get(self, request, slug, field_options):
|
||||
"""
|
||||
Lists all the rows of a grid view, paginated either by a page or offset/limit.
|
||||
If the limit get parameter is provided the limit/offset pagination will be used
|
||||
else the page number pagination.
|
||||
|
||||
Optionally the field options can also be included in the response if the the
|
||||
`field_options` are provided in the include GET parameter.
|
||||
"""
|
||||
|
||||
search = request.GET.get("search")
|
||||
|
||||
view_handler = ViewHandler()
|
||||
view = view_handler.get_public_view_by_slug(request.user, slug, GridView)
|
||||
view_type = view_type_registry.get_by_model(view)
|
||||
|
||||
publicly_visible_field_options = view_type.get_visible_field_options_in_order(
|
||||
view
|
||||
)
|
||||
publicly_visible_field_ids = {
|
||||
o.field_id for o in publicly_visible_field_options
|
||||
}
|
||||
|
||||
# We have to still make a model with all fields as the public rows should still
|
||||
# be filtered by hidden fields.
|
||||
model = view.table.get_model()
|
||||
queryset = view_handler.get_queryset(
|
||||
view,
|
||||
search,
|
||||
model,
|
||||
only_sort_by_field_ids=publicly_visible_field_ids,
|
||||
only_search_by_field_ids=publicly_visible_field_ids,
|
||||
)
|
||||
|
||||
if "count" in request.GET:
|
||||
return Response({"count": queryset.count()})
|
||||
|
||||
if LimitOffsetPagination.limit_query_param in request.GET:
|
||||
paginator = LimitOffsetPagination()
|
||||
else:
|
||||
paginator = PageNumberPagination()
|
||||
|
||||
page = paginator.paginate_queryset(queryset, request, self)
|
||||
serializer_class = get_row_serializer_class(
|
||||
model, RowSerializer, is_response=True, field_ids=publicly_visible_field_ids
|
||||
)
|
||||
serializer = serializer_class(page, many=True)
|
||||
|
||||
response = paginator.get_paginated_response(serializer.data)
|
||||
|
||||
if field_options:
|
||||
context = {"field_options": publicly_visible_field_options}
|
||||
serializer_class = view_type.get_field_options_serializer_class(
|
||||
create_if_missing=False
|
||||
)
|
||||
response.data.update(**serializer_class(view, context=context).data)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class PublicGridViewInfoView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
description="The slug of the grid view to get public information "
|
||||
"about.",
|
||||
)
|
||||
],
|
||||
tags=["Database table grid view"],
|
||||
operation_id="get_public_grid_view_info",
|
||||
description=(
|
||||
"Returns the required public information to display a single "
|
||||
"shared grid view."
|
||||
),
|
||||
request=None,
|
||||
responses={
|
||||
200: PublicGridViewInfoSerializer,
|
||||
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,
|
||||
}
|
||||
)
|
||||
@transaction.atomic
|
||||
def get(self, request, slug):
|
||||
|
||||
handler = ViewHandler()
|
||||
view = handler.get_public_view_by_slug(request.user, slug, view_model=GridView)
|
||||
grid_view_type = view_type_registry.get_by_model(view)
|
||||
field_options = grid_view_type.get_visible_field_options_in_order(view)
|
||||
|
||||
return Response(
|
||||
PublicGridViewInfoSerializer(
|
||||
view=view,
|
||||
fields=[o.field for o in field_options.select_related("field")],
|
||||
).data
|
||||
)
|
||||
|
|
|
@ -78,15 +78,17 @@ class FieldOptionsField(serializers.Field):
|
|||
"""
|
||||
|
||||
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")
|
||||
field_options = self.context.get("field_options", None)
|
||||
if field_options is None:
|
||||
# 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.
|
||||
field_options = value.get_field_options(
|
||||
self.create_if_missing, self.context.get("fields")
|
||||
)
|
||||
return {
|
||||
field_options.field_id: self.serializer_class(field_options).data
|
||||
for field_options in value.get_field_options(
|
||||
self.create_if_missing, fields
|
||||
)
|
||||
for field_options in field_options
|
||||
}
|
||||
else:
|
||||
return value
|
||||
|
|
|
@ -178,7 +178,7 @@ class QuerysetSerializer(abc.ABC):
|
|||
"""
|
||||
|
||||
view_type = view_type_registry.get_by_model(view.specific_class)
|
||||
fields, model = view_type.get_fields_and_model(view)
|
||||
fields, model = view_type.get_visible_fields_and_model(view)
|
||||
qs = ViewHandler().get_queryset(view, model=model)
|
||||
return cls(qs, fields)
|
||||
|
||||
|
|
|
@ -505,7 +505,6 @@ class FieldHandler:
|
|||
dependant_field_type,
|
||||
via_path_to_starting_table,
|
||||
) in dependant_fields:
|
||||
print(f"Telling {dependant_field.name} that {field.name} was deleted")
|
||||
dependant_field_type.field_dependency_deleted(
|
||||
dependant_field,
|
||||
field,
|
||||
|
|
|
@ -47,7 +47,7 @@ class TableModelQuerySet(models.QuerySet):
|
|||
)
|
||||
return self
|
||||
|
||||
def search_all_fields(self, search):
|
||||
def search_all_fields(self, search, only_search_by_field_ids=None):
|
||||
"""
|
||||
Performs a very broad search across all supported fields with the given search
|
||||
query. If the primary key value matches then that result will be returned
|
||||
|
@ -56,6 +56,10 @@ class TableModelQuerySet(models.QuerySet):
|
|||
|
||||
:param search: The search query.
|
||||
:type search: str
|
||||
:param only_search_by_field_ids: Only field ids in this iterable will be
|
||||
filtered by the search term. Other fields not in the iterable will be
|
||||
ignored and not be filtered.
|
||||
:type only_search_by_field_ids: Optional[Iterable[int]]
|
||||
:return: The queryset containing the search queries.
|
||||
:rtype: QuerySet
|
||||
"""
|
||||
|
@ -64,6 +68,11 @@ class TableModelQuerySet(models.QuerySet):
|
|||
Q(id__contains=search)
|
||||
)
|
||||
for field_object in self.model._field_objects.values():
|
||||
if (
|
||||
only_search_by_field_ids is not None
|
||||
and field_object["field"].id not in only_search_by_field_ids
|
||||
):
|
||||
continue
|
||||
field_name = field_object["name"]
|
||||
model_field = self.model._meta.get_field(field_name)
|
||||
|
||||
|
|
|
@ -507,7 +507,7 @@ class ViewHandler:
|
|||
self, view_filter_id=view_filter_id, view_filter=view_filter, user=user
|
||||
)
|
||||
|
||||
def apply_sorting(self, view, queryset):
|
||||
def apply_sorting(self, view, queryset, restrict_to_field_ids=None):
|
||||
"""
|
||||
Applies the view's sorting to the given queryset. The first sort, which for now
|
||||
is the first created, will always be applied first. Secondary sortings are
|
||||
|
@ -529,6 +529,9 @@ class ViewHandler:
|
|||
:type queryset: QuerySet
|
||||
:raises ValueError: When the queryset's model is not a table model or if the
|
||||
table model does not contain the one of the fields.
|
||||
:param restrict_to_field_ids: Only field ids in this iterable will have their
|
||||
view sorts applied in the resulting queryset.
|
||||
:type restrict_to_field_ids: Optional[Iterable[int]]
|
||||
:return: The queryset where the sorting has been applied to.
|
||||
:type: QuerySet
|
||||
"""
|
||||
|
@ -542,7 +545,10 @@ class ViewHandler:
|
|||
|
||||
order_by = []
|
||||
|
||||
for view_sort in view.viewsort_set.all():
|
||||
qs = view.viewsort_set
|
||||
if restrict_to_field_ids is not None:
|
||||
qs = qs.filter(field_id__in=restrict_to_field_ids)
|
||||
for view_sort in qs.all():
|
||||
# If the to be sort field is not present in the `_field_objects` we
|
||||
# cannot filter so we raise a ValueError.
|
||||
if view_sort.field_id not in model._field_objects:
|
||||
|
@ -755,7 +761,14 @@ class ViewHandler:
|
|||
self, view_sort_id=view_sort_id, view_sort=view_sort, user=user
|
||||
)
|
||||
|
||||
def get_queryset(self, view, search=None, model=None):
|
||||
def get_queryset(
|
||||
self,
|
||||
view,
|
||||
search=None,
|
||||
model=None,
|
||||
only_sort_by_field_ids=None,
|
||||
only_search_by_field_ids=None,
|
||||
):
|
||||
"""
|
||||
Returns a queryset for the provided view which is appropriately sorted,
|
||||
filtered and searched according to the view type and its settings.
|
||||
|
@ -765,7 +778,17 @@ class ViewHandler:
|
|||
not specified then the model will be generated automatically.
|
||||
:param view: The view to get the export queryset and fields for.
|
||||
:type view: View
|
||||
:return: The export queryset.
|
||||
:param only_sort_by_field_ids: To only sort the queryset by some fields
|
||||
provide those field ids in this optional iterable. Other fields not
|
||||
present in the iterable will not have their view sorts applied even if they
|
||||
have one.
|
||||
:type only_sort_by_field_ids: Optional[Iterable[int]]
|
||||
:param only_search_by_field_ids: To only apply the search term to some
|
||||
fields provide those field ids in this optional iterable. Other fields
|
||||
not present in the iterable will not be searched and filtered down by the
|
||||
search term.
|
||||
:type only_search_by_field_ids: Optional[Iterable[int]]
|
||||
:return: The appropriate queryset for the provided view.
|
||||
:rtype: QuerySet
|
||||
"""
|
||||
|
||||
|
@ -778,9 +801,9 @@ class ViewHandler:
|
|||
if view_type.can_filter:
|
||||
queryset = self.apply_filters(view, queryset)
|
||||
if view_type.can_sort:
|
||||
queryset = self.apply_sorting(view, queryset)
|
||||
queryset = self.apply_sorting(view, queryset, only_sort_by_field_ids)
|
||||
if search is not None:
|
||||
queryset = queryset.search_all_fields(search)
|
||||
queryset = queryset.search_all_fields(search, only_search_by_field_ids)
|
||||
return queryset
|
||||
|
||||
def rotate_view_slug(self, user, view):
|
||||
|
@ -836,6 +859,9 @@ class ViewHandler:
|
|||
except (view_model.DoesNotExist, ValidationError):
|
||||
raise ViewDoesNotExist("The view does not exist.")
|
||||
|
||||
if TrashHandler.item_has_a_trashed_parent(view.table, check_item_also=True):
|
||||
raise ViewDoesNotExist("The view does not exist.")
|
||||
|
||||
if not view.public and (
|
||||
not user or not view.table.database.group.has_user(user)
|
||||
):
|
||||
|
|
|
@ -238,7 +238,7 @@ class ViewType(
|
|||
|
||||
return view
|
||||
|
||||
def get_fields_and_model(self, view):
|
||||
def get_visible_fields_and_model(self, view):
|
||||
"""
|
||||
Returns the field objects for the provided view. Depending on the view type this
|
||||
will only return the visible or appropriate fields as different view types can
|
||||
|
|
|
@ -33,6 +33,7 @@ class GridViewType(ViewType):
|
|||
model_class = GridView
|
||||
field_options_model_class = GridViewFieldOptions
|
||||
field_options_serializer_class = GridViewFieldOptionsSerializer
|
||||
can_share = True
|
||||
|
||||
def get_api_urls(self):
|
||||
from baserow.contrib.database.api.views.grid import urls as api_urls
|
||||
|
@ -94,7 +95,7 @@ class GridViewType(ViewType):
|
|||
|
||||
return grid_view
|
||||
|
||||
def get_fields_and_model(self, view):
|
||||
def get_visible_fields_and_model(self, view):
|
||||
"""
|
||||
Returns the model and the field options in the correct order for exporting
|
||||
this view type.
|
||||
|
@ -102,19 +103,21 @@ class GridViewType(ViewType):
|
|||
|
||||
grid_view = ViewHandler().get_view(view.id, view_model=GridView)
|
||||
|
||||
ordered_field_objects = []
|
||||
ordered_visible_fields = (
|
||||
# Ensure all fields have options created before we then query directly off
|
||||
# the options table below.
|
||||
ordered_visible_field_ids = self.get_visible_field_options_in_order(
|
||||
grid_view
|
||||
).values_list("field__id", flat=True)
|
||||
model = grid_view.table.get_model(field_ids=ordered_visible_field_ids)
|
||||
ordered_field_objects = [
|
||||
model._field_objects[field_id] for field_id in ordered_visible_field_ids
|
||||
]
|
||||
return ordered_field_objects, model
|
||||
|
||||
def get_visible_field_options_in_order(self, grid_view):
|
||||
return (
|
||||
grid_view.get_field_options(create_if_not_exists=True)
|
||||
.filter(hidden=False)
|
||||
.order_by("-field__primary", "order", "field__id")
|
||||
.values_list("field__id", flat=True)
|
||||
)
|
||||
model = view.table.get_model(field_ids=ordered_visible_fields)
|
||||
for field_id in ordered_visible_fields:
|
||||
ordered_field_objects.append(model._field_objects[field_id])
|
||||
return ordered_field_objects, model
|
||||
|
||||
|
||||
class GalleryViewType(ViewType):
|
||||
|
|
|
@ -71,7 +71,7 @@ class CustomFieldsInstanceMixin:
|
|||
**kwargs,
|
||||
)
|
||||
|
||||
def get_serializer(self, model_instance, base_class=None, **kwargs):
|
||||
def get_serializer(self, model_instance, base_class=None, context=None, **kwargs):
|
||||
"""
|
||||
Returns an instantiated model serializer based on this type field names and
|
||||
overrides. The provided model instance will be used instantiate the serializer.
|
||||
|
@ -81,16 +81,21 @@ class CustomFieldsInstanceMixin:
|
|||
:param base_class: The base serializer class that must be extended. For example
|
||||
common fields could be stored here.
|
||||
:type base_class: ModelSerializer
|
||||
:param context: Extra context arguments to pass to the serializers context.
|
||||
:type kwargs: dict
|
||||
:param kwargs: The kwargs are used to initialize the serializer class.
|
||||
:type kwargs: dict
|
||||
:return: The instantiated generated model serializer.
|
||||
:rtype: ModelSerializer
|
||||
"""
|
||||
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
model_instance = model_instance.specific
|
||||
serializer_class = self.get_serializer_class(base_class=base_class)
|
||||
return serializer_class(
|
||||
model_instance, context={"instance_type": self}, **kwargs
|
||||
model_instance, context={"instance_type": self, **context}, **kwargs
|
||||
)
|
||||
|
||||
|
||||
|
@ -351,7 +356,7 @@ class ModelRegistryMixin:
|
|||
|
||||
|
||||
class CustomFieldsRegistryMixin:
|
||||
def get_serializer(self, model_instance, base_class=None, **kwargs):
|
||||
def get_serializer(self, model_instance, base_class=None, context=None, **kwargs):
|
||||
"""
|
||||
Based on the provided model_instance and base_class a unique serializer
|
||||
containing the correct field type is generated.
|
||||
|
@ -361,6 +366,8 @@ class CustomFieldsRegistryMixin:
|
|||
:param base_class: The base serializer class that must be extended. For example
|
||||
common fields could be stored here.
|
||||
:type base_class: ModelSerializer
|
||||
:param context: Extra context arguments to pass to the serializers context.
|
||||
:type kwargs: dict
|
||||
:param kwargs: The kwargs are used to initialize the serializer class.
|
||||
:type kwargs: dict
|
||||
:raises ValueError: When the `get_by_model` method was not found, which could
|
||||
|
@ -379,7 +386,7 @@ class CustomFieldsRegistryMixin:
|
|||
|
||||
instance_type = self.get_by_model(model_instance.specific_class)
|
||||
return instance_type.get_serializer(
|
||||
model_instance, base_class=base_class, **kwargs
|
||||
model_instance, base_class=base_class, context=context, **kwargs
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ from baserow.contrib.database.views.models import (
|
|||
|
||||
|
||||
class ViewFixtures:
|
||||
def create_grid_view(self, user=None, **kwargs):
|
||||
def create_grid_view(self, user=None, create_options=True, **kwargs):
|
||||
if "table" not in kwargs:
|
||||
kwargs["table"] = self.create_database_table(user=user)
|
||||
|
||||
|
@ -23,7 +23,8 @@ class ViewFixtures:
|
|||
kwargs["order"] = 0
|
||||
|
||||
grid_view = GridView.objects.create(**kwargs)
|
||||
self.create_grid_view_field_options(grid_view)
|
||||
if create_options:
|
||||
self.create_grid_view_field_options(grid_view)
|
||||
return grid_view
|
||||
|
||||
def create_grid_view_field_options(self, grid_view, **kwargs):
|
||||
|
|
|
@ -11,11 +11,14 @@ from rest_framework.status import (
|
|||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
|
||||
from baserow.contrib.database.rows.handler import RowHandler
|
||||
from baserow.contrib.database.rows.registries import (
|
||||
RowMetadataType,
|
||||
row_metadata_registry,
|
||||
)
|
||||
from baserow.contrib.database.views.models import GridView
|
||||
from baserow.core.trash.handler import TrashHandler
|
||||
from baserow.test_utils.helpers import register_instance_temporarily
|
||||
|
||||
|
||||
|
@ -649,7 +652,7 @@ def test_create_grid_view(api_client, data_fixture):
|
|||
# Can't create a public non sharable view.
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{"name": "Test 1", "type": "grid", "public": True},
|
||||
{"name": "Test 1", "type": "gallery", "public": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
@ -667,6 +670,7 @@ def test_update_grid_view(api_client, data_fixture):
|
|||
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)
|
||||
not_sharable_view = data_fixture.create_gallery_view(table=table)
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view_2.id})
|
||||
response = api_client.patch(
|
||||
|
@ -748,12 +752,368 @@ def test_update_grid_view(api_client, data_fixture):
|
|||
|
||||
# Can't make a non sharable view public.
|
||||
response = api_client.patch(
|
||||
reverse("api:database:views:item", kwargs={"view_id": view.id}),
|
||||
reverse("api:database:views:item", kwargs={"view_id": not_sharable_view.id}),
|
||||
{"public": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.status_code == HTTP_200_OK, response_json
|
||||
assert "public" not in response_json
|
||||
assert "slug" not in response_json
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_public_grid_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
||||
# Only information related the public field should be returned
|
||||
public_field = data_fixture.create_text_field(table=table, name="public")
|
||||
hidden_field = data_fixture.create_text_field(table=table, name="hidden")
|
||||
|
||||
grid_view = data_fixture.create_grid_view(
|
||||
table=table, user=user, public=True, create_options=False
|
||||
)
|
||||
|
||||
data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False)
|
||||
data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True)
|
||||
|
||||
# This view sort shouldn't be exposed as it is for a hidden field
|
||||
data_fixture.create_view_sort(view=grid_view, field=hidden_field, order="ASC")
|
||||
visible_sort = data_fixture.create_view_sort(
|
||||
view=grid_view, field=public_field, order="DESC"
|
||||
)
|
||||
|
||||
# View filters should not be returned at all for any and all fields regardless of
|
||||
# if they are hidden.
|
||||
data_fixture.create_view_filter(
|
||||
view=grid_view, field=hidden_field, type="contains", value="hidden"
|
||||
)
|
||||
data_fixture.create_view_filter(
|
||||
view=grid_view, field=public_field, type="contains", value="public"
|
||||
)
|
||||
|
||||
# Can access as an anonymous user
|
||||
response = api_client.get(
|
||||
reverse("api:database:views:grid:public_info", kwargs={"slug": grid_view.slug})
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK, response_json
|
||||
assert response_json == {
|
||||
"fields": [
|
||||
{
|
||||
"id": public_field.id,
|
||||
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
|
||||
"name": "public",
|
||||
"order": 0,
|
||||
"primary": False,
|
||||
"text_default": "",
|
||||
"type": "text",
|
||||
}
|
||||
],
|
||||
"view": {
|
||||
"id": grid_view.slug,
|
||||
"name": grid_view.name,
|
||||
"order": 0,
|
||||
"public": True,
|
||||
"slug": grid_view.slug,
|
||||
"sortings": [
|
||||
# Note the sorting for the hidden field is not returned
|
||||
{
|
||||
"field": visible_sort.field.id,
|
||||
"id": visible_sort.id,
|
||||
"order": "DESC",
|
||||
"view": grid_view.slug,
|
||||
}
|
||||
],
|
||||
"table": {
|
||||
"database_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
|
||||
"id": PUBLIC_PLACEHOLDER_ENTITY_ID,
|
||||
},
|
||||
"type": "grid",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anon_user_cant_get_info_about_a_non_public_grid_view(api_client, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user, public=False)
|
||||
|
||||
# Get access as an anonymous user
|
||||
response = api_client.get(
|
||||
reverse("api:database:views:grid:public_info", kwargs={"slug": grid_view.slug})
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response_json == {
|
||||
"detail": "The requested view does not exist.",
|
||||
"error": "ERROR_VIEW_DOES_NOT_EXIST",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_in_wrong_group_cant_get_info_about_a_non_public_grid_view(
|
||||
api_client, data_fixture
|
||||
):
|
||||
user = data_fixture.create_user()
|
||||
other_user, other_user_token = data_fixture.create_user_and_token()
|
||||
grid_view = data_fixture.create_grid_view(user=user, public=False)
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
"api:database:views:grid:public_info",
|
||||
kwargs={"slug": grid_view.slug},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {other_user_token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response_json == {
|
||||
"detail": "The requested view does not exist.",
|
||||
"error": "ERROR_VIEW_DOES_NOT_EXIST",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_in_same_group_can_get_info_about_a_non_public_grid_view(
|
||||
api_client, data_fixture
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
grid_view = data_fixture.create_grid_view(user=user, public=False)
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
"api:database:views:grid:public_info",
|
||||
kwargs={"slug": grid_view.slug},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK, response_json
|
||||
assert "fields" in response_json
|
||||
assert "view" in response_json
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_get_info_about_non_grid_view(api_client, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
form_view = data_fixture.create_form_view(user=user, public=True)
|
||||
|
||||
# Get access as an anonymous user
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
"api:database:views:grid:public_info",
|
||||
kwargs={"slug": form_view.slug},
|
||||
),
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response_json == {
|
||||
"detail": "The requested view does not exist.",
|
||||
"error": "ERROR_VIEW_DOES_NOT_EXIST",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_get_info_about_trashed_grid_view(api_client, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user, public=True)
|
||||
|
||||
TrashHandler.trash(
|
||||
user, grid_view.table.database.group, None, grid_view.table.database.group
|
||||
)
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
"api:database:views:grid:public_info",
|
||||
kwargs={"slug": grid_view.slug},
|
||||
),
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response_json == {
|
||||
"detail": "The requested view does not exist.",
|
||||
"error": "ERROR_VIEW_DOES_NOT_EXIST",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_rows_public_doesnt_show_hidden_columns(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
||||
# Only information related the public field should be returned
|
||||
public_field = data_fixture.create_text_field(table=table, name="public")
|
||||
hidden_field = data_fixture.create_text_field(table=table, name="hidden")
|
||||
|
||||
grid_view = data_fixture.create_grid_view(
|
||||
table=table, user=user, public=True, create_options=False
|
||||
)
|
||||
|
||||
public_field_option = data_fixture.create_grid_view_field_option(
|
||||
grid_view, public_field, hidden=False
|
||||
)
|
||||
data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True)
|
||||
|
||||
RowHandler().create_row(user, table, values={})
|
||||
|
||||
# Get access as an anonymous user
|
||||
response = api_client.get(
|
||||
reverse("api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug})
|
||||
+ "?include=field_options"
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
f"field_{public_field.id}": None,
|
||||
"id": 1,
|
||||
"order": "1.00000000000000000000",
|
||||
}
|
||||
],
|
||||
"field_options": {
|
||||
f"{public_field.id}": {
|
||||
"hidden": False,
|
||||
"order": public_field_option.order,
|
||||
"width": public_field_option.width,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_rows_public_doesnt_sort_by_hidden_columns(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
||||
# Only information related the public field should be returned
|
||||
public_field = data_fixture.create_text_field(table=table, name="public")
|
||||
hidden_field = data_fixture.create_text_field(table=table, name="hidden")
|
||||
|
||||
grid_view = data_fixture.create_grid_view(
|
||||
table=table, user=user, public=True, create_options=False
|
||||
)
|
||||
|
||||
data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False)
|
||||
data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True)
|
||||
|
||||
second_row = RowHandler().create_row(
|
||||
user, table, values={"public": "a", "hidden": "y"}, user_field_names=True
|
||||
)
|
||||
first_row = RowHandler().create_row(
|
||||
user, table, values={"public": "b", "hidden": "z"}, user_field_names=True
|
||||
)
|
||||
|
||||
data_fixture.create_view_sort(view=grid_view, field=hidden_field, order="ASC")
|
||||
data_fixture.create_view_sort(view=grid_view, field=public_field, order="DESC")
|
||||
|
||||
# Get access as an anonymous user
|
||||
response = api_client.get(
|
||||
reverse("api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug})
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK, response_json
|
||||
assert response_json["results"][0]["id"] == first_row.id
|
||||
assert response_json["results"][1]["id"] == second_row.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_rows_public_filters_by_visible_and_hidden_columns(
|
||||
api_client, data_fixture
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
||||
# Only information related the public field should be returned
|
||||
public_field = data_fixture.create_text_field(table=table, name="public")
|
||||
hidden_field = data_fixture.create_text_field(table=table, name="hidden")
|
||||
|
||||
grid_view = data_fixture.create_grid_view(
|
||||
table=table, user=user, public=True, create_options=False
|
||||
)
|
||||
|
||||
data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False)
|
||||
data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True)
|
||||
|
||||
data_fixture.create_view_filter(
|
||||
view=grid_view, field=hidden_field, type="equal", value="y"
|
||||
)
|
||||
data_fixture.create_view_filter(
|
||||
view=grid_view, field=public_field, type="equal", value="a"
|
||||
)
|
||||
# A row whose hidden column doesn't match the first filter
|
||||
RowHandler().create_row(
|
||||
user, table, values={"public": "a", "hidden": "not y"}, user_field_names=True
|
||||
)
|
||||
# A row whose public column doesn't match the second filter
|
||||
RowHandler().create_row(
|
||||
user, table, values={"public": "not a", "hidden": "y"}, user_field_names=True
|
||||
)
|
||||
# A row which matches all filters
|
||||
visible_row = RowHandler().create_row(
|
||||
user, table, values={"public": "a", "hidden": "y"}, user_field_names=True
|
||||
)
|
||||
|
||||
# Get access as an anonymous user
|
||||
response = api_client.get(
|
||||
reverse("api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug})
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK, response_json
|
||||
assert len(response_json["results"]) == 1
|
||||
assert response_json["count"] == 1
|
||||
assert response_json["results"][0]["id"] == visible_row.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_rows_public_only_searches_by_visible_columns(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
||||
# Only information related the public field should be returned
|
||||
public_field = data_fixture.create_text_field(table=table, name="public")
|
||||
hidden_field = data_fixture.create_text_field(table=table, name="hidden")
|
||||
|
||||
grid_view = data_fixture.create_grid_view(
|
||||
table=table, user=user, public=True, create_options=False
|
||||
)
|
||||
|
||||
data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False)
|
||||
data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True)
|
||||
|
||||
search_term = "search_term"
|
||||
RowHandler().create_row(
|
||||
user,
|
||||
table,
|
||||
values={"public": "other", "hidden": search_term},
|
||||
user_field_names=True,
|
||||
)
|
||||
RowHandler().create_row(
|
||||
user,
|
||||
table,
|
||||
values={"public": "other", "hidden": "other"},
|
||||
user_field_names=True,
|
||||
)
|
||||
visible_row = RowHandler().create_row(
|
||||
user,
|
||||
table,
|
||||
values={"public": search_term, "hidden": "other"},
|
||||
user_field_names=True,
|
||||
)
|
||||
|
||||
# Get access as an anonymous user
|
||||
response = api_client.get(
|
||||
reverse("api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug})
|
||||
+ f"?search={search_term}"
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK, response_json
|
||||
assert len(response_json["results"]) == 1
|
||||
assert response_json["count"] == 1
|
||||
assert response_json["results"][0]["id"] == visible_row.id
|
||||
|
|
|
@ -1317,6 +1317,9 @@ def test_patch_view_field_options(api_client, data_fixture):
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_rotate_slug(api_client, data_fixture):
|
||||
class UnShareableViewType(GridViewType):
|
||||
can_share = False
|
||||
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
view = data_fixture.create_form_view(table=table)
|
||||
|
@ -1329,10 +1332,15 @@ def test_rotate_slug(api_client, data_fixture):
|
|||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
url = reverse("api:database:views:rotate_slug", kwargs={"view_id": grid_view.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_CANNOT_SHARE_VIEW_TYPE"
|
||||
with patch.dict(view_type_registry.registry, {"grid": UnShareableViewType()}):
|
||||
url = reverse(
|
||||
"api:database:views:rotate_slug", kwargs={"view_id": grid_view.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_CANNOT_SHARE_VIEW_TYPE"
|
||||
|
||||
url = reverse("api:database:views:rotate_slug", kwargs={"view_id": 99999})
|
||||
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
|
||||
|
|
|
@ -4,6 +4,7 @@ from decimal import Decimal
|
|||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from baserow.contrib.database.views.view_types import GridViewType
|
||||
from baserow.core.exceptions import UserNotInGroup
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import (
|
||||
|
@ -1265,6 +1266,9 @@ def test_delete_sort(send_mock, data_fixture):
|
|||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_updated.send")
|
||||
def test_rotate_view_slug(send_mock, data_fixture):
|
||||
class UnShareableViewType(GridViewType):
|
||||
can_share = False
|
||||
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
@ -1277,8 +1281,9 @@ def test_rotate_view_slug(send_mock, data_fixture):
|
|||
with pytest.raises(UserNotInGroup):
|
||||
handler.rotate_view_slug(user=user_2, view=form)
|
||||
|
||||
with pytest.raises(CannotShareViewTypeError):
|
||||
handler.rotate_view_slug(user=user, view=grid)
|
||||
with patch.dict(view_type_registry.registry, {"grid": UnShareableViewType()}):
|
||||
with pytest.raises(CannotShareViewTypeError):
|
||||
handler.rotate_view_slug(user=user, view=grid)
|
||||
|
||||
handler.rotate_view_slug(user=user, view=form)
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
`/database/views/${viewId}/rotate-slug/`.
|
||||
* Increased maximum length of application name to 160 characters.
|
||||
* Fixed copying/pasting for date field.
|
||||
* Added ability to share grid views publicly.
|
||||
* Allow changing the text of the submit button in the form view.
|
||||
* Fixed reordering of single select options when initially creating the field.
|
||||
* Improved performance by not rendering cells that are out of the view port.
|
||||
|
|
|
@ -152,6 +152,10 @@ export default {
|
|||
grid: 'Grid',
|
||||
gallery: 'Gallery',
|
||||
form: 'Form',
|
||||
sharing: {
|
||||
linkName: 'view',
|
||||
formLinkName: 'form',
|
||||
},
|
||||
},
|
||||
premium: {
|
||||
deactivated: 'Available in premium version',
|
||||
|
|
|
@ -154,6 +154,10 @@ export default {
|
|||
grid: 'Tableau',
|
||||
gallery: 'Gallerie',
|
||||
form: 'Formulaire',
|
||||
sharing: {
|
||||
linkName: '@todo',
|
||||
formLinkName: '@todo',
|
||||
},
|
||||
},
|
||||
premium: {
|
||||
deactivated: 'Désactivé',
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
@import 'views/grid/many_to_many';
|
||||
@import 'views/grid/array';
|
||||
@import 'views/gallery';
|
||||
@import 'views/sharing';
|
||||
@import 'array_field';
|
||||
@import 'views/form';
|
||||
@import 'box_page';
|
||||
|
|
|
@ -494,116 +494,6 @@
|
|||
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;
|
||||
|
|
|
@ -561,3 +561,8 @@
|
|||
|
||||
border-top: solid 1px $color-primary-900;
|
||||
}
|
||||
|
||||
.grid-view__public-shared-page {
|
||||
min-height: 100%;
|
||||
background-color: $white;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
.view-sharing__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-sharing__create-link--disabled {
|
||||
color: $color-neutral-500;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
cursor: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-sharing__create-link-icon {
|
||||
margin-right: 20px;
|
||||
color: $color-warning-500;
|
||||
|
||||
.view-sharing__create-link--disabled & {
|
||||
color: $color-neutral-500;
|
||||
}
|
||||
}
|
||||
|
||||
.view-sharing__shared-link {
|
||||
padding: 25px 25px;
|
||||
}
|
||||
|
||||
.view-sharing__shared-link-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.view-sharing__shared-link-description {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.view-sharing__shared-link-content {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.view-sharing__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-sharing__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-sharing__shared-link-action--loading):hover {
|
||||
background-color: $color-neutral-100;
|
||||
}
|
||||
|
||||
&.view-sharing__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-sharing__shared-link-action--disabled {
|
||||
color: $color-primary-900;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
cursor: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-sharing__shared-link-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.view-sharing__shared-link-disable {
|
||||
color: $color-error-500;
|
||||
}
|
|
@ -94,17 +94,12 @@ export const actions = {
|
|||
/**
|
||||
* Fetches all the applications for the authenticated user.
|
||||
*/
|
||||
async fetchAll({ commit }) {
|
||||
async fetchAll({ commit, dispatch }) {
|
||||
commit('SET_LOADING', true)
|
||||
|
||||
try {
|
||||
const { data } = await ApplicationService(this.$client).fetchAll()
|
||||
data.forEach((part, index, d) => {
|
||||
populateApplication(data[index], this.$registry)
|
||||
})
|
||||
commit('SET_ITEMS', data)
|
||||
commit('SET_LOADING', false)
|
||||
commit('SET_LOADED', true)
|
||||
await dispatch('forceSetAll', { applications: data })
|
||||
} catch (error) {
|
||||
commit('SET_ITEMS', [])
|
||||
commit('SET_LOADING', false)
|
||||
|
@ -112,6 +107,15 @@ export const actions = {
|
|||
throw error
|
||||
}
|
||||
},
|
||||
forceSetAll({ commit }, { applications }) {
|
||||
applications.forEach((part, index) => {
|
||||
populateApplication(applications[index], this.$registry)
|
||||
})
|
||||
commit('SET_ITEMS', applications)
|
||||
commit('SET_LOADING', false)
|
||||
commit('SET_LOADED', true)
|
||||
return { applications }
|
||||
},
|
||||
/**
|
||||
* Clears all the currently selected applications, this could be called when
|
||||
* the group is deleted of when the user logs off.
|
||||
|
|
|
@ -92,6 +92,12 @@
|
|||
@changed="refresh()"
|
||||
></ViewSort>
|
||||
</li>
|
||||
<li
|
||||
v-if="hasSelectedView && view._.type.canShare && !readOnly"
|
||||
class="header__filter-item"
|
||||
>
|
||||
<ShareViewLink :view="view" :read-only="readOnly"></ShareViewLink>
|
||||
</li>
|
||||
</ul>
|
||||
<component
|
||||
:is="getViewHeaderComponent(view)"
|
||||
|
@ -136,6 +142,7 @@ import ViewFilter from '@baserow/modules/database/components/view/ViewFilter'
|
|||
import ViewSort from '@baserow/modules/database/components/view/ViewSort'
|
||||
import ViewSearch from '@baserow/modules/database/components/view/ViewSearch'
|
||||
import EditableViewName from '@baserow/modules/database/components/view/EditableViewName'
|
||||
import ShareViewLink from '@baserow/modules/database/components/view/ShareViewLink'
|
||||
|
||||
/**
|
||||
* This page component is the skeleton for a table. Depending on the selected view it
|
||||
|
@ -143,6 +150,7 @@ import EditableViewName from '@baserow/modules/database/components/view/Editable
|
|||
*/
|
||||
export default {
|
||||
components: {
|
||||
ShareViewLink,
|
||||
EditableViewName,
|
||||
ViewsContext,
|
||||
ViewFilter,
|
||||
|
|
159
web-frontend/modules/database/components/view/ShareViewLink.vue
Normal file
159
web-frontend/modules/database/components/view/ShareViewLink.vue
Normal file
|
@ -0,0 +1,159 @@
|
|||
<template>
|
||||
<div>
|
||||
<a
|
||||
ref="contextLink"
|
||||
class="header__filter-link"
|
||||
:class="{ 'active--primary': 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">{{
|
||||
$t('shareViewLink.shareView', { viewTypeSharingLinkName })
|
||||
}}</span>
|
||||
</a>
|
||||
<Context ref="context">
|
||||
<a
|
||||
v-if="!view.public"
|
||||
class="view-sharing__create-link"
|
||||
:class="{ 'view-sharing__create-link--disabled': readOnly }"
|
||||
@click.stop="!readOnly && updateView({ public: true })"
|
||||
>
|
||||
<i class="fas fa-share-square view-sharing__create-link-icon"></i>
|
||||
{{ $t('shareViewLink.shareViewTitle', { viewTypeSharingLinkName }) }}
|
||||
</a>
|
||||
<div v-else class="view-sharing__shared-link">
|
||||
<div class="view-sharing__shared-link-title">
|
||||
{{ $t('shareViewLink.sharedViewTitle', { viewTypeSharingLinkName }) }}
|
||||
</div>
|
||||
<div class="view-sharing__shared-link-description">
|
||||
{{
|
||||
$t('shareViewLink.sharedViewDescription', {
|
||||
viewTypeSharingLinkName,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="view-sharing__shared-link-content">
|
||||
<div class="view-sharing__shared-link-box">
|
||||
{{ shareUrl }}
|
||||
</div>
|
||||
<a
|
||||
v-tooltip="$t('shareViewLink.copyURL')"
|
||||
class="view-sharing__shared-link-action"
|
||||
@click="copyShareUrlToClipboard()"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
<Copied ref="copied"></Copied>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="!readOnly" class="view-sharing__shared-link-foot">
|
||||
<a
|
||||
class="view-sharing__shared-link-disable"
|
||||
@click.stop="updateView({ public: false })"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
{{ $t('shareViewLink.disableLink') }}
|
||||
</a>
|
||||
<a @click.prevent="$refs.rotateSlugModal.show()">
|
||||
<i class="fas fa-sync"></i>
|
||||
{{ $t('shareViewLink.generateNewUrl') }}
|
||||
</a>
|
||||
<ViewRotateSlugModal
|
||||
ref="rotateSlugModal"
|
||||
:view="view"
|
||||
></ViewRotateSlugModal>
|
||||
</div>
|
||||
</div>
|
||||
</Context>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
|
||||
import ViewRotateSlugModal from '@baserow/modules/database/components/view/ViewRotateSlugModal'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export default {
|
||||
name: 'ShareViewLink',
|
||||
components: { ViewRotateSlugModal },
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rotateSlugLoading: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
shareUrl() {
|
||||
return (
|
||||
this.$env.PUBLIC_WEB_FRONTEND_URL +
|
||||
this.$nuxt.$router.resolve({
|
||||
name: this.viewType.getPublicRoute(),
|
||||
params: { slug: this.view.slug },
|
||||
}).href
|
||||
)
|
||||
},
|
||||
viewType() {
|
||||
return this.$registry.get('view', this.view.type)
|
||||
},
|
||||
viewTypeSharingLinkName() {
|
||||
return this.viewType.getSharingLinkName()
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
copyShareUrlToClipboard() {
|
||||
copyToClipboard(this.shareUrl)
|
||||
this.$refs.copied.show()
|
||||
},
|
||||
async updateView(values) {
|
||||
const view = this.view
|
||||
this.$store.dispatch('view/setItemLoading', { view, value: true })
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('view/update', {
|
||||
view,
|
||||
values,
|
||||
})
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
|
||||
this.$store.dispatch('view/setItemLoading', { view, value: false })
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"shareViewLink": {
|
||||
"shareView": "Share {viewTypeSharingLinkName}",
|
||||
"shareViewTitle": "Create a private shareable link to the {viewTypeSharingLinkName}",
|
||||
"sharedViewTitle": "This {viewTypeSharingLinkName} is currently shared via a private link",
|
||||
"sharedViewDescription": "People who have the link can see the {viewTypeSharingLinkName}.",
|
||||
"disableLink": "disable shared link",
|
||||
"generateNewUrl": "generate new url",
|
||||
"copyURL": "copy URL"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"shareViewLink": {
|
||||
"shareView": "@todo",
|
||||
"shareViewTitle": "@todo",
|
||||
"sharedViewTitle": "@todo",
|
||||
"sharedViewDescription": "@todo",
|
||||
"disableLink": "@todo",
|
||||
"generateNewUrl": "@todo",
|
||||
"copyURL": "@todo"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
|
@ -1,13 +1,15 @@
|
|||
<template>
|
||||
<Modal>
|
||||
<h2 class="box__title">Refresh URL</h2>
|
||||
<h2 class="box__title">{{ $t('viewRotateSlugModal.title') }}</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.
|
||||
{{
|
||||
$t('viewRotateSlugModal.refreshWarning', {
|
||||
viewName: view.name,
|
||||
viewTypeSharingLinkName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<div class="align-right">
|
||||
|
@ -17,7 +19,7 @@
|
|||
:disabled="loading"
|
||||
@click="rotateSlug()"
|
||||
>
|
||||
Generate new URL
|
||||
{{ $t('viewRotateSlugModal.generateNewURL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -28,12 +30,11 @@
|
|||
<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 ViewService from '@baserow/modules/database/services/view'
|
||||
|
||||
export default {
|
||||
name: 'FormViewRotateSlugModal',
|
||||
mixins: [modal, error, formViewHelpers],
|
||||
name: 'ViewRotateSlugModal',
|
||||
mixins: [modal, error],
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
|
@ -45,6 +46,11 @@ export default {
|
|||
loading: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
viewTypeSharingLinkName() {
|
||||
return this.$registry.get('view', this.view.type).getSharingLinkName()
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async rotateSlug() {
|
||||
this.hideError()
|
||||
|
@ -68,3 +74,23 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"viewRotateSlugModal": {
|
||||
"title": "Refresh URL",
|
||||
"refreshWarning": "Are you sure that you want to refresh the URL of {viewName}? After refreshing, a new URL will be generated and it will not be possible to access the {viewTypeSharingLinkName} via the old URL. Everyone that you have shared the URL with, won't be able to access the {viewTypeSharingLinkName}.",
|
||||
"generateNewURL": "Generate new URL"
|
||||
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"viewRotateSlugModal": {
|
||||
"title": "@todo",
|
||||
"refreshWarning": "@todo",
|
||||
"generateNewURL": "@todo"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
|
@ -1,133 +0,0 @@
|
|||
<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">
|
||||
<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>
|
82
web-frontend/modules/database/pages/publicGridView.vue
Normal file
82
web-frontend/modules/database/pages/publicGridView.vue
Normal file
|
@ -0,0 +1,82 @@
|
|||
<template>
|
||||
<div>
|
||||
<Notifications></Notifications>
|
||||
<div class="grid-view__public-shared-page">
|
||||
<Table
|
||||
:database="database"
|
||||
:table="table"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
:views="[view]"
|
||||
:view="view"
|
||||
:read-only="true"
|
||||
:table-loading="false"
|
||||
:store-prefix="'page/'"
|
||||
></Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Notifications from '@baserow/modules/core/components/notifications/Notifications'
|
||||
import Table from '@baserow/modules/database/components/table/Table'
|
||||
import GridService from '@baserow/modules/database/services/view/grid'
|
||||
import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes'
|
||||
import { PUBLIC_PLACEHOLDER_ENTITY_ID } from '@baserow/modules/database/utils/constants'
|
||||
|
||||
export default {
|
||||
components: { Notifications, Table },
|
||||
/**
|
||||
* Fetches and prepares all the table, field and view data for the provided
|
||||
* public grid view.
|
||||
*/
|
||||
async asyncData({ store, params, error, app }) {
|
||||
try {
|
||||
const viewSlug = params.slug
|
||||
await store.dispatch('page/view/grid/setPublic', true)
|
||||
const { data } = await GridService(app.$client).fetchPublicViewInfo(
|
||||
viewSlug
|
||||
)
|
||||
|
||||
const { applications } = await store.dispatch('application/forceSetAll', {
|
||||
applications: [
|
||||
{
|
||||
id: PUBLIC_PLACEHOLDER_ENTITY_ID,
|
||||
type: DatabaseApplicationType.getType(),
|
||||
tables: [{ id: PUBLIC_PLACEHOLDER_ENTITY_ID }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const database = applications[0]
|
||||
const table = database.tables[0]
|
||||
await store.dispatch('table/forceSelect', { database, table })
|
||||
|
||||
const { primary, fields } = await store.dispatch('field/forceSetFields', {
|
||||
fields: data.fields,
|
||||
})
|
||||
const { view } = await store.dispatch('view/forceCreate', {
|
||||
data: data.view,
|
||||
})
|
||||
await store.dispatch('view/select', view)
|
||||
|
||||
// It might be possible that the view also has some stores that need to be
|
||||
// filled with initial data, so we're going to call the fetch function here.
|
||||
const type = app.$registry.get('view', view.type)
|
||||
await type.fetch({ store }, view, fields, primary, 'page/')
|
||||
return {
|
||||
database,
|
||||
table,
|
||||
view,
|
||||
primary,
|
||||
fields,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.response && e.response.status === 404) {
|
||||
return error({ statusCode: 404, message: 'View not found.' })
|
||||
} else {
|
||||
return error({ statusCode: 500, message: 'Error loading view.' })
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -45,4 +45,9 @@ export const routes = [
|
|||
path: '/form/:slug',
|
||||
component: path.resolve(__dirname, 'pages/form.vue'),
|
||||
},
|
||||
{
|
||||
name: 'database-public-grid-view',
|
||||
path: '/public/grid/:slug',
|
||||
component: path.resolve(__dirname, 'pages/publicGridView.vue'),
|
||||
},
|
||||
]
|
||||
|
|
|
@ -8,6 +8,7 @@ export default (client) => {
|
|||
includeFieldOptions = false,
|
||||
includeRowMetadata = true,
|
||||
search = false,
|
||||
publicUrl = false,
|
||||
}) {
|
||||
const config = {
|
||||
params: {
|
||||
|
@ -40,9 +41,11 @@ export default (client) => {
|
|||
config.params.search = search
|
||||
}
|
||||
|
||||
return client.get(`/database/views/grid/${gridId}/`, config)
|
||||
const url = publicUrl ? 'public/rows/' : ''
|
||||
|
||||
return client.get(`/database/views/grid/${gridId}/${url}`, config)
|
||||
},
|
||||
fetchCount({ gridId, search, cancelToken = null }) {
|
||||
fetchCount({ gridId, search, cancelToken = null, publicUrl = false }) {
|
||||
const config = {
|
||||
params: {
|
||||
count: true,
|
||||
|
@ -56,7 +59,9 @@ export default (client) => {
|
|||
config.params.search = search
|
||||
}
|
||||
|
||||
return client.get(`/database/views/grid/${gridId}/`, config)
|
||||
const url = publicUrl ? 'public/rows/' : ''
|
||||
|
||||
return client.get(`/database/views/grid/${gridId}/${url}`, config)
|
||||
},
|
||||
filterRows({ gridId, rowIds, fieldIds = null }) {
|
||||
const data = { row_ids: rowIds }
|
||||
|
@ -67,5 +72,8 @@ export default (client) => {
|
|||
|
||||
return client.post(`/database/views/grid/${gridId}/`, data)
|
||||
},
|
||||
fetchPublicViewInfo(viewSlug) {
|
||||
return client.get(`/database/views/grid/${viewSlug}/public/info/`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,18 +82,7 @@ export const actions = {
|
|||
|
||||
try {
|
||||
const { data } = await FieldService(this.$client).fetchAll(table.id)
|
||||
data.forEach((part, index, d) => {
|
||||
populateField(data[index], this.$registry)
|
||||
})
|
||||
|
||||
const primaryIndex = data.findIndex((item) => item.primary === true)
|
||||
const primary =
|
||||
primaryIndex !== -1 ? data.splice(primaryIndex, 1)[0] : null
|
||||
commit('SET_PRIMARY', primary)
|
||||
|
||||
commit('SET_ITEMS', data)
|
||||
commit('SET_LOADING', false)
|
||||
commit('SET_LOADED', true)
|
||||
await dispatch('forceSetFields', { fields: data })
|
||||
} catch (error) {
|
||||
commit('SET_ITEMS', [])
|
||||
commit('SET_LOADING', false)
|
||||
|
@ -101,6 +90,21 @@ export const actions = {
|
|||
throw error
|
||||
}
|
||||
},
|
||||
forceSetFields({ commit }, { fields }) {
|
||||
fields.forEach((part, index) => {
|
||||
populateField(fields[index], this.$registry)
|
||||
})
|
||||
|
||||
const primaryIndex = fields.findIndex((item) => item.primary === true)
|
||||
const primary =
|
||||
primaryIndex !== -1 ? fields.splice(primaryIndex, 1)[0] : null
|
||||
commit('SET_PRIMARY', primary)
|
||||
commit('SET_ITEMS', fields)
|
||||
commit('SET_LOADING', false)
|
||||
commit('SET_LOADED', true)
|
||||
|
||||
return { primary, fields }
|
||||
},
|
||||
/**
|
||||
* Creates a new field with the provided type for the given table.
|
||||
*/
|
||||
|
@ -172,7 +176,11 @@ export const actions = {
|
|||
const { commit, dispatch } = context
|
||||
const fieldType = this.$registry.get('field', values.type)
|
||||
const data = populateField(values, this.$registry)
|
||||
commit('ADD_ITEM', data)
|
||||
if (data.primary) {
|
||||
commit('SET_PRIMARY', data)
|
||||
} else {
|
||||
commit('ADD_ITEM', data)
|
||||
}
|
||||
|
||||
// Call the field created event on all the registered views because they might
|
||||
// need to change things in loaded data. For example the grid field will add the
|
||||
|
@ -227,6 +235,7 @@ export const actions = {
|
|||
data = populateField(data, this.$registry)
|
||||
|
||||
if (field.primary) {
|
||||
console.log('setting primary in force update')
|
||||
commit('SET_PRIMARY', data)
|
||||
} else {
|
||||
commit('UPDATE_ITEM', { id: field.id, values: data })
|
||||
|
|
|
@ -177,9 +177,12 @@ export const actions = {
|
|||
dispatch('field/fetchAll', table, { root: true }),
|
||||
])
|
||||
await dispatch('application/clearChildrenSelected', null, { root: true })
|
||||
commit('SET_SELECTED', { database, table })
|
||||
await dispatch('forceSelect', { database, table })
|
||||
return { database, table }
|
||||
},
|
||||
forceSelect({ commit }, { database, table }) {
|
||||
commit('SET_SELECTED', { database, table })
|
||||
},
|
||||
/**
|
||||
* Selects a table based on the provided database (application) and table id. The
|
||||
* application will also be selected if it has not already been selected. Because the
|
||||
|
|
|
@ -245,6 +245,7 @@ export const actions = {
|
|||
forceCreate({ commit }, { data }) {
|
||||
populateView(data, this.$registry)
|
||||
commit('ADD_ITEM', data)
|
||||
return { view: data }
|
||||
},
|
||||
/**
|
||||
* Updates the values of the view with the provided id.
|
||||
|
|
|
@ -83,6 +83,7 @@ export const state = () => ({
|
|||
// entirely out. When false no server filter will be applied and rows which do not
|
||||
// have any matching cells will still be displayed.
|
||||
hideRowsNotMatchingSearch: true,
|
||||
public: false,
|
||||
})
|
||||
|
||||
export const mutations = {
|
||||
|
@ -100,6 +101,9 @@ export const mutations = {
|
|||
state.activeSearchTerm = ''
|
||||
state.hideRowsNotMatchingSearch = true
|
||||
},
|
||||
SET_PUBLIC(state, newPublicValue) {
|
||||
state.public = newPublicValue
|
||||
},
|
||||
SET_SEARCH(state, { activeSearchTerm, hideRowsNotMatchingSearch }) {
|
||||
state.activeSearchTerm = activeSearchTerm
|
||||
state.hideRowsNotMatchingSearch = hideRowsNotMatchingSearch
|
||||
|
@ -475,6 +479,7 @@ export const actions = {
|
|||
limit: requestLimit,
|
||||
cancelToken: lastSource.token,
|
||||
search: getters.getServerSearchTerm,
|
||||
publicUrl: getters.isPublic,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
data.results.forEach((part, index) => {
|
||||
|
@ -632,6 +637,7 @@ export const actions = {
|
|||
limit,
|
||||
includeFieldOptions: true,
|
||||
search: getters.getServerSearchTerm,
|
||||
publicUrl: getters.isPublic,
|
||||
})
|
||||
data.results.forEach((part, index) => {
|
||||
extractMetadataAndPopulateRow(data, index)
|
||||
|
@ -675,6 +681,7 @@ export const actions = {
|
|||
gridId,
|
||||
search: getters.getServerSearchTerm,
|
||||
cancelToken: lastRefreshRequestSource.token,
|
||||
publicUrl: getters.isPublic,
|
||||
})
|
||||
.then((response) => {
|
||||
const count = response.data.count
|
||||
|
@ -696,6 +703,7 @@ export const actions = {
|
|||
includeFieldOptions,
|
||||
cancelToken: lastRefreshRequestSource.token,
|
||||
search: getters.getServerSearchTerm,
|
||||
publicUrl: getters.isPublic,
|
||||
})
|
||||
.then(({ data }) => ({
|
||||
data,
|
||||
|
@ -1528,12 +1536,18 @@ export const actions = {
|
|||
commit('UPDATE_ROW_METADATA', { row, rowMetadataType, updateFunction })
|
||||
}
|
||||
},
|
||||
setPublic({ commit }, newPublicValue) {
|
||||
commit('SET_PUBLIC', newPublicValue)
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
isLoaded(state) {
|
||||
return state.loaded
|
||||
},
|
||||
isPublic(state) {
|
||||
return state.public
|
||||
},
|
||||
getLastGridId(state) {
|
||||
return state.lastGridId
|
||||
},
|
||||
|
|
|
@ -2,3 +2,7 @@ export const trueString = ['y', 't', 'o', 'yes', 'true', 'on', '1']
|
|||
// Please keep in sync with src/baserow/contrib/database/fields/handler.py:30
|
||||
export const RESERVED_BASEROW_FIELD_NAMES = ['id', 'order']
|
||||
export const MAX_FIELD_NAME_LENGTH = 255
|
||||
|
||||
// Please keep in sync with
|
||||
// src/baserow/contrib/database/api/views/grid/serializers.py:15:PUBLIC_PLACEHOLDER_ENTITY_ID
|
||||
export const PUBLIC_PLACEHOLDER_ENTITY_ID = 0
|
||||
|
|
|
@ -5,7 +5,6 @@ import GridViewHeader from '@baserow/modules/database/components/view/grid/GridV
|
|||
import GalleryView from '@baserow/modules/database/components/view/gallery/GalleryView'
|
||||
import GalleryViewHeader from '@baserow/modules/database/components/view/gallery/GalleryViewHeader'
|
||||
import FormView from '@baserow/modules/database/components/view/form/FormView'
|
||||
import FormViewHeader from '@baserow/modules/database/components/view/form/FormViewHeader'
|
||||
|
||||
export const maxPossibleOrderValue = 32767
|
||||
|
||||
|
@ -50,6 +49,13 @@ export class ViewType extends Registerable {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether it is possible to share this view via an url publically.
|
||||
*/
|
||||
canShare() {
|
||||
return false
|
||||
}
|
||||
|
||||
constructor(...args) {
|
||||
super(...args)
|
||||
this.type = this.getType()
|
||||
|
@ -57,6 +63,7 @@ export class ViewType extends Registerable {
|
|||
this.colorClass = this.getColorClass()
|
||||
this.canFilter = this.canFilter()
|
||||
this.canSort = this.canSort()
|
||||
this.canShare = this.canShare()
|
||||
|
||||
if (this.type === null) {
|
||||
throw new Error('The type name of a view type must be set.')
|
||||
|
@ -83,7 +90,30 @@ export class ViewType extends Registerable {
|
|||
* Should return the component that will actually display the view.
|
||||
*/
|
||||
getComponent() {
|
||||
throw new Error('Not implement error. This view should return a component.')
|
||||
throw new Error(
|
||||
'Not implemented error. This view should return a component.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return a route name that will display the view when it has been
|
||||
* publicly shared.
|
||||
*/
|
||||
getPublicRoute() {
|
||||
throw new Error(
|
||||
'Not implemented error. This method should be implemented to return a route' +
|
||||
' name to a public page where the view can be seen.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A human readable name of the view type to be used in the ShareViewLink and
|
||||
* related components. For example the link to share to view will have the text:
|
||||
* `Share {this.getSharingLinkName()}`
|
||||
*/
|
||||
getSharingLinkName() {
|
||||
const { i18n } = this.app
|
||||
return i18n.t('viewType.sharing.linkName')
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -245,6 +275,7 @@ export class ViewType extends Registerable {
|
|||
name: this.getName(),
|
||||
canFilter: this.canFilter,
|
||||
canSort: this.canSort,
|
||||
canShare: this.canShare,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -283,6 +314,14 @@ export class GridViewType extends ViewType {
|
|||
return GridView
|
||||
}
|
||||
|
||||
canShare() {
|
||||
return true
|
||||
}
|
||||
|
||||
getPublicRoute() {
|
||||
return 'database-public-grid-view'
|
||||
}
|
||||
|
||||
async fetch({ store }, view, fields, primary, storePrefix = '') {
|
||||
await store.dispatch(storePrefix + 'view/grid/fetchInitial', {
|
||||
gridId: view.id,
|
||||
|
@ -680,8 +719,17 @@ export class FormViewType extends ViewType {
|
|||
return false
|
||||
}
|
||||
|
||||
getHeaderComponent() {
|
||||
return FormViewHeader
|
||||
canShare() {
|
||||
return true
|
||||
}
|
||||
|
||||
getPublicRoute() {
|
||||
return 'database-table-form'
|
||||
}
|
||||
|
||||
getSharingLinkName() {
|
||||
const { i18n } = this.app
|
||||
return i18n.t('viewType.sharing.formLinkName')
|
||||
}
|
||||
|
||||
getComponent() {
|
||||
|
|
16
web-frontend/test/fixtures/grid.js
vendored
16
web-frontend/test/fixtures/grid.js
vendored
|
@ -13,3 +13,19 @@ export function createRows(mock, gridView, fields, rows = []) {
|
|||
field_options: fieldOptions,
|
||||
})
|
||||
}
|
||||
|
||||
export function createPublicGridViewRows(mock, viewSlug, fields, rows = []) {
|
||||
const fieldOptions = {}
|
||||
for (let i = 1; i < fields.length; i++) {
|
||||
fieldOptions[i] = {
|
||||
width: 200,
|
||||
hidden: false,
|
||||
order: i,
|
||||
}
|
||||
}
|
||||
mock.onGet(`/database/views/grid/${viewSlug}/public/rows/`).reply(200, {
|
||||
count: rows.length,
|
||||
results: rows,
|
||||
field_options: fieldOptions,
|
||||
})
|
||||
}
|
||||
|
|
21
web-frontend/test/fixtures/mockServer.js
vendored
21
web-frontend/test/fixtures/mockServer.js
vendored
|
@ -1,8 +1,14 @@
|
|||
import { createApplication } from '@baserow/test/fixtures/applications'
|
||||
import { createGroup } from '@baserow/test/fixtures/groups'
|
||||
import { createGridView } from '@baserow/test/fixtures/view'
|
||||
import {
|
||||
createGridView,
|
||||
createPublicGridView,
|
||||
} from '@baserow/test/fixtures/view'
|
||||
import { createFields } from '@baserow/test/fixtures/fields'
|
||||
import { createRows } from '@baserow/test/fixtures/grid'
|
||||
import {
|
||||
createPublicGridViewRows,
|
||||
createRows,
|
||||
} from '@baserow/test/fixtures/grid'
|
||||
|
||||
/**
|
||||
* MockServer is responsible for being the single place where we mock out calls to the
|
||||
|
@ -30,12 +36,21 @@ export class MockServer {
|
|||
return { id: 1, name: 'Test Table 1' }
|
||||
}
|
||||
|
||||
createGridView(application, table, filters = []) {
|
||||
createGridView(application, table, { filters = [], sortings = [] }) {
|
||||
return createGridView(this.mock, application, table, {
|
||||
filters,
|
||||
sortings,
|
||||
})
|
||||
}
|
||||
|
||||
createPublicGridView(viewSlug, { name, fields = [], sortings = [] }) {
|
||||
return createPublicGridView(this.mock, viewSlug, { name, fields, sortings })
|
||||
}
|
||||
|
||||
createPublicGridViewRows(viewSlug, fields, rows) {
|
||||
return createPublicGridViewRows(this.mock, viewSlug, fields, rows)
|
||||
}
|
||||
|
||||
createFields(application, table, fields) {
|
||||
return createFields(this.mock, application, table, fields)
|
||||
}
|
||||
|
|
31
web-frontend/test/fixtures/view.js
vendored
31
web-frontend/test/fixtures/view.js
vendored
|
@ -1,8 +1,36 @@
|
|||
import { PUBLIC_PLACEHOLDER_ENTITY_ID } from '@baserow/modules/database/utils/constants'
|
||||
|
||||
export function createPublicGridView(
|
||||
mock,
|
||||
viewSlug,
|
||||
{ name, fields = [], sortings = [] }
|
||||
) {
|
||||
if (name === undefined) {
|
||||
name = `public_mock_view_${viewSlug}`
|
||||
}
|
||||
const publicGridView = {
|
||||
id: viewSlug,
|
||||
table: {
|
||||
id: PUBLIC_PLACEHOLDER_ENTITY_ID,
|
||||
database_id: PUBLIC_PLACEHOLDER_ENTITY_ID,
|
||||
},
|
||||
order: 0,
|
||||
name,
|
||||
type: 'grid',
|
||||
public: true,
|
||||
slug: viewSlug,
|
||||
sortings,
|
||||
}
|
||||
mock
|
||||
.onGet(`/database/views/grid/${viewSlug}/public/info/`)
|
||||
.reply(200, { view: publicGridView, fields })
|
||||
}
|
||||
|
||||
export function createGridView(
|
||||
mock,
|
||||
application,
|
||||
table,
|
||||
{ viewType = 'grid', viewId = 1, filters = [] }
|
||||
{ viewType = 'grid', viewId = 1, filters = [], publicView = false }
|
||||
) {
|
||||
const tableId = table.id
|
||||
const gridView = {
|
||||
|
@ -19,6 +47,7 @@ export function createGridView(
|
|||
},
|
||||
filter_type: 'AND',
|
||||
filters_disabled: false,
|
||||
public: publicView,
|
||||
filters,
|
||||
}
|
||||
mock.onGet(`/database/views/table/${tableId}/`).reply(200, [gridView])
|
||||
|
|
|
@ -0,0 +1,563 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="notifications"
|
||||
>
|
||||
<div
|
||||
class="top-right-notifications"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bottom-right-notifications"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__public-shared-page"
|
||||
>
|
||||
<div>
|
||||
<header
|
||||
class="layout__col-2-1 header"
|
||||
>
|
||||
<div
|
||||
class="header__loading"
|
||||
style="display: none;"
|
||||
/>
|
||||
|
||||
<ul
|
||||
class="header__filter"
|
||||
>
|
||||
<li
|
||||
class="header__filter-item header__filter-item--grids"
|
||||
>
|
||||
<a
|
||||
class="header__filter-link"
|
||||
>
|
||||
<i
|
||||
class="header__filter-icon header-filter-icon--view fas fa-fw color-primary fa-bars"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="header__filter-name header__filter-name--forced"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
contenteditable="false"
|
||||
>
|
||||
my public grid view name
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</li>
|
||||
|
||||
<!---->
|
||||
|
||||
<li
|
||||
class="header__filter-item"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
class="header__filter-link"
|
||||
>
|
||||
<i
|
||||
class="header__filter-icon fas fa-filter"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="header__filter-name"
|
||||
>
|
||||
viewFilter.filter - 0
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
class="header__filter-item"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
class="header__filter-link"
|
||||
>
|
||||
<i
|
||||
class="header__filter-icon fas fa-sort"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="header__filter-name"
|
||||
>
|
||||
viewSort.sort - 0
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!---->
|
||||
</ul>
|
||||
|
||||
<ul
|
||||
class="header__filter header__filter--full-width"
|
||||
database="[object Object]"
|
||||
table="[object Object]"
|
||||
>
|
||||
<li
|
||||
class="header__filter-item"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
class="header__filter-link"
|
||||
>
|
||||
<i
|
||||
class="header__filter-icon fas fa-eye-slash"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="header__filter-name"
|
||||
>
|
||||
gridViewHide.hideField - 0
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
class="header__filter-item header__filter-item--right"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
class="header__filter-link"
|
||||
>
|
||||
<i
|
||||
class="header__search-icon fas fa-search"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="layout__col-2-2 content"
|
||||
>
|
||||
<div
|
||||
class="grid-view"
|
||||
>
|
||||
<div
|
||||
class="scrollbars"
|
||||
style="left: 260px;"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__left"
|
||||
style="width: 260px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__inner"
|
||||
style="min-width: 260px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__head"
|
||||
>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
style="width: 60px;"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid-view__column"
|
||||
filters=""
|
||||
style="width: 200px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description-icon"
|
||||
>
|
||||
<i
|
||||
class="fas fa-font"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__description-name"
|
||||
>
|
||||
|
||||
Name
|
||||
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__body"
|
||||
>
|
||||
<div
|
||||
class="grid-view__body-inner"
|
||||
>
|
||||
<div
|
||||
class="grid-view__placeholder"
|
||||
style="height: 33px; width: 260px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__placeholder-column"
|
||||
style="left: 199px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__rows"
|
||||
style="transform: translateY(0px) translateX(0px);"
|
||||
>
|
||||
<div
|
||||
class="grid-view__row"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<div
|
||||
class="grid-view__column"
|
||||
style="width: 60px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__row-info"
|
||||
>
|
||||
<div
|
||||
class="grid-view__row-count"
|
||||
title="1"
|
||||
>
|
||||
|
||||
1
|
||||
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
|
||||
<a
|
||||
class="grid-view__row-more"
|
||||
>
|
||||
<i
|
||||
class="fas fa-expand"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__column"
|
||||
style="width: 200px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__cell"
|
||||
>
|
||||
<div
|
||||
class="grid-field-text"
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__foot"
|
||||
>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
style="width: 260px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__foot-info"
|
||||
>
|
||||
|
||||
gridView.rowCount - 1
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="display: none;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__field-dragging"
|
||||
style="width: 0px; left: 0px;"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid-view__field-target"
|
||||
style="left: 0px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__divider"
|
||||
style="left: 260px;"
|
||||
/>
|
||||
|
||||
<!---->
|
||||
|
||||
<div
|
||||
class="grid-view__right"
|
||||
style="left: 260px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__inner"
|
||||
style="min-width: 800px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__head"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<div
|
||||
class="grid-view__column"
|
||||
filters=""
|
||||
style="width: 200px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description-icon"
|
||||
>
|
||||
<i
|
||||
class="fas fa-font"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__description-name"
|
||||
>
|
||||
|
||||
Last name
|
||||
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
filters=""
|
||||
style="width: 200px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description-icon"
|
||||
>
|
||||
<i
|
||||
class="fas fa-align-left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__description-name"
|
||||
>
|
||||
|
||||
Notes
|
||||
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
filters=""
|
||||
style="width: 200px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description-icon"
|
||||
>
|
||||
<i
|
||||
class="fas fa-check-square"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__description-name"
|
||||
>
|
||||
|
||||
Active
|
||||
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__body"
|
||||
>
|
||||
<div
|
||||
class="grid-view__body-inner"
|
||||
>
|
||||
<div
|
||||
class="grid-view__placeholder"
|
||||
style="height: 33px; width: 600px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__placeholder-column"
|
||||
style="left: 199px;"
|
||||
/>
|
||||
<div
|
||||
class="grid-view__placeholder-column"
|
||||
style="left: 399px;"
|
||||
/>
|
||||
<div
|
||||
class="grid-view__placeholder-column"
|
||||
style="left: 599px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__rows"
|
||||
style="transform: translateY(0px) translateX(0px);"
|
||||
>
|
||||
<div
|
||||
class="grid-view__row"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<div
|
||||
class="grid-view__column"
|
||||
style="width: 200px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__cell"
|
||||
>
|
||||
<div
|
||||
class="grid-field-text"
|
||||
>
|
||||
name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
style="width: 200px;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__cell grid-field-long-text__cell"
|
||||
>
|
||||
<div
|
||||
class="grid-field-long-text"
|
||||
>
|
||||
last_name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__foot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="display: none;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__field-dragging"
|
||||
style="width: 0px; left: 0px;"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid-view__field-target"
|
||||
style="left: 0px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid-view__row-dragging-container"
|
||||
style="display: none;"
|
||||
>
|
||||
<div
|
||||
class="grid-view__row-dragging"
|
||||
style="width: 860px; top: 0px;"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid-view__row-target"
|
||||
style="width: 860px; top: 0px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
75
web-frontend/test/unit/database/publicGrid.spec.js
Normal file
75
web-frontend/test/unit/database/publicGrid.spec.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { TestApp } from '@baserow/test/helpers/testApp'
|
||||
import PublicGrid from '@baserow/modules/database/pages/publicGridView'
|
||||
|
||||
// Mock out debounce so we dont have to wait or simulate waiting for the various
|
||||
// debounces in the search functionality.
|
||||
jest.mock('lodash/debounce', () => jest.fn((fn) => fn))
|
||||
|
||||
describe('Public View Page Tests', () => {
|
||||
let testApp = null
|
||||
let mockServer = null
|
||||
|
||||
beforeAll(() => {
|
||||
testApp = new TestApp()
|
||||
mockServer = testApp.mockServer
|
||||
})
|
||||
|
||||
afterEach(() => testApp.afterEach())
|
||||
|
||||
test('Can see a publicly shared grid view', async () => {
|
||||
const slug = 'testSlug'
|
||||
const gridViewName = 'my public grid view name'
|
||||
givenAPubliclySharedGridViewWithSlug(gridViewName, slug)
|
||||
|
||||
const publicGridViewPage = await testApp.mount(PublicGrid, {
|
||||
asyncDataParams: {
|
||||
slug,
|
||||
},
|
||||
})
|
||||
|
||||
expect(publicGridViewPage.html()).toContain(gridViewName)
|
||||
expect(publicGridViewPage.element).toMatchSnapshot()
|
||||
})
|
||||
|
||||
function givenAPubliclySharedGridViewWithSlug(name, slug) {
|
||||
const fields = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Name',
|
||||
type: 'text',
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Last name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Notes',
|
||||
type: 'long_text',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Active',
|
||||
type: 'boolean',
|
||||
},
|
||||
]
|
||||
const gridView = mockServer.createPublicGridView(slug, {
|
||||
name,
|
||||
fields,
|
||||
})
|
||||
mockServer.createPublicGridViewRows(slug, fields, [
|
||||
{
|
||||
id: 1,
|
||||
order: 0,
|
||||
field_1: 'name',
|
||||
field_2: 'last_name',
|
||||
field_3: 'notes',
|
||||
field_4: false,
|
||||
},
|
||||
])
|
||||
|
||||
return { gridView }
|
||||
}
|
||||
})
|
|
@ -123,7 +123,7 @@ describe('Table Component Tests', () => {
|
|||
async function givenASingleSimpleTableInTheServer() {
|
||||
const table = mockServer.createTable()
|
||||
const { application } = await mockServer.createAppAndGroup(table)
|
||||
const gridView = mockServer.createGridView(application, table)
|
||||
const gridView = mockServer.createGridView(application, table, {})
|
||||
const fields = mockServer.createFields(application, table, [
|
||||
{
|
||||
name: 'Name',
|
||||
|
|
|
@ -49,7 +49,9 @@ describe('View Filter Tests', () => {
|
|||
async function thereIsATableWithRowAndFilter(field, row, filter) {
|
||||
const table = mockServer.createTable()
|
||||
const { application } = await mockServer.createAppAndGroup(table)
|
||||
const gridView = mockServer.createGridView(application, table, [filter])
|
||||
const gridView = mockServer.createGridView(application, table, {
|
||||
filters: [filter],
|
||||
})
|
||||
const fields = mockServer.createFields(application, table, [field])
|
||||
|
||||
mockServer.createRows(gridView, fields, [row])
|
||||
|
|
Loading…
Add table
Reference in a new issue