1
0
Fork 0
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:
Nigel Gott 2022-01-06 18:17:01 +00:00
parent 7595377e34
commit 133fceb6cf
45 changed files with 2038 additions and 333 deletions

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -152,6 +152,10 @@ export default {
grid: 'Grid',
gallery: 'Gallery',
form: 'Form',
sharing: {
linkName: 'view',
formLinkName: 'form',
},
},
premium: {
deactivated: 'Available in premium version',

View file

@ -154,6 +154,10 @@ export default {
grid: 'Tableau',
gallery: 'Gallerie',
form: 'Formulaire',
sharing: {
linkName: '@todo',
formLinkName: '@todo',
},
},
premium: {
deactivated: 'Désactivé',

View file

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

View file

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

View file

@ -561,3 +561,8 @@
border-top: solid 1px $color-primary-900;
}
.grid-view__public-shared-page {
min-height: 100%;
background-color: $white;
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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