diff --git a/backend/src/baserow/contrib/database/api/views/urls.py b/backend/src/baserow/contrib/database/api/views/urls.py index 54b456ab6..b0b210ea8 100644 --- a/backend/src/baserow/contrib/database/api/views/urls.py +++ b/backend/src/baserow/contrib/database/api/views/urls.py @@ -5,6 +5,7 @@ from baserow.contrib.database.views.registries import view_type_registry from .views import ( ViewsView, ViewView, + DuplicateViewView, OrderViewsView, ViewFiltersView, ViewFilterView, @@ -45,6 +46,11 @@ urlpatterns = view_type_registry.api_urls + [ name="decoration_item", ), re_path(r"(?P<view_id>[0-9]+)/$", ViewView.as_view(), name="item"), + re_path( + r"(?P<view_id>[0-9]+)/duplicate/$", + DuplicateViewView.as_view(), + name="duplicate", + ), re_path( r"(?P<view_id>[0-9]+)/filters/$", ViewFiltersView.as_view(), name="list_filters" ), diff --git a/backend/src/baserow/contrib/database/api/views/views.py b/backend/src/baserow/contrib/database/api/views/views.py index 103ff6cea..301974e5a 100644 --- a/backend/src/baserow/contrib/database/api/views/views.py +++ b/backend/src/baserow/contrib/database/api/views/views.py @@ -14,6 +14,7 @@ from baserow.contrib.database.views.actions import ( CreateViewActionType, DeleteViewActionType, OrderViewsActionType, + DuplicateViewActionType, UpdateViewActionType, CreateViewFilterActionType, DeleteViewFilterActionType, @@ -519,6 +520,70 @@ class ViewView(APIView): return Response(status=204) +class DuplicateViewView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="view_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + description="Duplicates the view related to the provided value.", + ), + CLIENT_SESSION_ID_SCHEMA_PARAMETER, + ], + tags=["Database table views"], + operation_id="duplicate_database_table_view", + description=( + "Duplicates an existing view if the user has access to it. " + "When a view is duplicated everthing is copied except:" + "\n- The name is appended with the copy number. " + "Ex: `View Name` -> `View Name (2)` and `View (2)` -> `View (3)`" + "\n- If the original view is publicly shared, the new view will not be" + " shared anymore" + ), + responses={ + 200: DiscriminatorCustomFieldsMappingSerializer( + view_type_registry, ViewSerializer + ), + 400: get_error_schema( + [ + "ERROR_USER_NOT_IN_GROUP", + ] + ), + 404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]), + }, + ) + @transaction.atomic + @map_exceptions( + { + ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, + UserNotInGroup: ERROR_USER_NOT_IN_GROUP, + } + ) + def post(self, request, view_id): + """Duplicates a view.""" + + view = ViewHandler().get_view(view_id).specific + + view_type = view_type_registry.get_by_model(view) + + with view_type.map_api_exceptions(): + duplicate_view = action_type_registry.get_by_type( + DuplicateViewActionType + ).do(user=request.user, original_view=view) + + serializer = view_type_registry.get_serializer( + duplicate_view, + ViewSerializer, + filters=True, + sortings=True, + decorations=True, + ) + return Response(serializer.data) + + class OrderViewsView(APIView): permission_classes = (IsAuthenticated,) diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index dd247da60..e1bec9df7 100644 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -95,6 +95,7 @@ class DatabaseConfig(AppConfig): from baserow.contrib.database.views.actions import ( CreateViewActionType, + DuplicateViewActionType, DeleteViewActionType, OrderViewsActionType, UpdateViewActionType, @@ -112,6 +113,7 @@ class DatabaseConfig(AppConfig): ) action_type_registry.register(CreateViewActionType()) + action_type_registry.register(DuplicateViewActionType()) action_type_registry.register(DeleteViewActionType()) action_type_registry.register(OrderViewsActionType()) action_type_registry.register(UpdateViewActionType()) diff --git a/backend/src/baserow/contrib/database/fields/handler.py b/backend/src/baserow/contrib/database/fields/handler.py index 5cc2b1a3d..7f373c2b1 100644 --- a/backend/src/baserow/contrib/database/fields/handler.py +++ b/backend/src/baserow/contrib/database/fields/handler.py @@ -40,7 +40,7 @@ from baserow.contrib.database.table.models import Table from baserow.contrib.database.views.handler import ViewHandler from baserow.core.trash.exceptions import RelatedTableTrashedException from baserow.core.trash.handler import TrashHandler -from baserow.core.utils import extract_allowed, set_allowed_attrs +from baserow.core.utils import extract_allowed, set_allowed_attrs, find_unused_name from .dependencies.handler import FieldDependencyHandler from .dependencies.update_collector import FieldUpdateCollector from .exceptions import ( @@ -755,64 +755,21 @@ class FieldHandler: max_field_name_length = Field.get_max_name_length() - # If the field_name_to_try is longer than the maximally allowed - # field name length the name needs to be truncated. - field_names_to_try = [ - item[0:max_field_name_length] for item in field_names_to_try - ] - # Check if any of the names to try are available by finding any existing field - # names with the same name. - taken_field_names = set( - Field.objects.exclude(id__in=field_ids_to_ignore) - .filter(table=table, name__in=field_names_to_try) - .values("name") - .distinct() - .values_list("name", flat=True) - ) - # If there are more names to try than the ones used in the table then there must - # be one which isn't used. - if len(set(field_names_to_try)) > len(taken_field_names): - # Loop over to ensure we maintain the ordering provided by - # field_names_to_try, so we always return the first available name and - # not any. - for field_name in field_names_to_try: - if field_name not in taken_field_names: - return field_name - - # None of the names in the param list are available, now using the last one lets - # append a number to the name until we find a free one. - original_field_name = field_names_to_try[-1] - # Lookup any existing field names. This way we can skip these and ensure our # new field has a unique name. - existing_field_name_collisions = set( + existing_field_name_collisions = ( Field.objects.exclude(id__in=field_ids_to_ignore) .filter(table=table) .order_by("name") .distinct() .values_list("name", flat=True) ) - i = 2 - while True: - suffix_to_append = f" {i}" - suffix_length = len(suffix_to_append) - length_of_original_field_name_plus_suffix = ( - len(original_field_name) + suffix_length - ) - # At this point we know, that the original_field_name can only - # be maximally the length of max_field_name_length. Therefore - # if the length_of_original_field_name_plus_suffix is longer - # we can further truncate the field_name by the length of the - # suffix. - if length_of_original_field_name_plus_suffix > max_field_name_length: - field_name = f"{original_field_name[:-suffix_length]}{suffix_to_append}" - else: - field_name = f"{original_field_name}{suffix_to_append}" - - i += 1 - if field_name not in existing_field_name_collisions: - return field_name + return find_unused_name( + field_names_to_try, + existing_field_name_collisions, + max_length=max_field_name_length, + ) def restore_field( self, diff --git a/backend/src/baserow/contrib/database/views/actions.py b/backend/src/baserow/contrib/database/views/actions.py index a82c0b6cb..f6da4eee7 100644 --- a/backend/src/baserow/contrib/database/views/actions.py +++ b/backend/src/baserow/contrib/database/views/actions.py @@ -754,6 +754,51 @@ class CreateViewActionType(ActionType): TrashHandler.restore_item(user, "view", params.view_id) +class DuplicateViewActionType(ActionType): + type = "duplicate_view" + + @dataclasses.dataclass + class Params: + view_id: int + + @classmethod + def do(cls, user: AbstractUser, original_view: View) -> View: + """ + Duplicate an existing view. + + Undoing this action deletes the new view. + Redoing this action restores the view. + + :param user: The user creating the view. + :param original_view: The view to duplicate. + """ + + view = ViewHandler().duplicate_view( + user, + original_view, + ) + + cls.register_action( + user=user, + params=cls.Params(view.id), + scope=cls.scope(original_view.table.id), + ) + + return view + + @classmethod + def scope(cls, table_id: int) -> ActionScopeStr: + return TableActionScopeType.value(table_id) + + @classmethod + def undo(cls, user: AbstractUser, params: Params, action_to_undo: Action): + ViewHandler().delete_view_by_id(user, params.view_id) + + @classmethod + def redo(cls, user: AbstractUser, params: Params, action_to_redo: Action): + TrashHandler.restore_item(user, "view", params.view_id) + + class DeleteViewActionType(ActionType): type = "delete_view" diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 8bc6417f4..b59e28fd5 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -1,6 +1,8 @@ +import re from collections import defaultdict from dataclasses import dataclass from copy import deepcopy +from io import BytesIO from typing import ( Dict, Any, @@ -12,6 +14,7 @@ from typing import ( Type, Union, ) +from zipfile import ZIP_DEFLATED, ZipFile import jwt @@ -24,6 +27,7 @@ from django.core.cache import cache from django.db import models as django_models from django.db.models import F, Count from django.db.models.query import QuerySet +from django.core.files.storage import default_storage from baserow.contrib.database.fields.exceptions import FieldNotInTable from baserow.contrib.database.fields.field_filters import FilterBuilder @@ -38,6 +42,7 @@ from baserow.core.utils import ( extract_allowed, set_allowed_attrs, get_model_reference_field_name, + find_unused_name, ) from .exceptions import ( ViewDoesNotExist, @@ -84,10 +89,12 @@ from .signals import ( ) from .validators import EMPTY_VALUES - FieldOptionsDict = Dict[int, Dict[str, Any]] +ending_number_regex = re.compile(r"(.+) (\d+)$") + + class ViewHandler: PUBLIC_VIEW_TOKEN_ALGORITHM = "HS256" # nosec @@ -203,6 +210,80 @@ class ViewHandler: return instance + def duplicate_view(self, user: AbstractUser, original_view: View) -> View: + """ + Duplicates the given view to create a new one. The name is appended with the + copy number and if the original view is publicly shared, the created view + will not be shared anymore. The new view will be created just after the original + view. + + :param user: The user whose ask for the duplication. + :param original_view: The original view to be duplicated. + :return: The created view instance. + """ + + group = original_view.table.database.group + group.has_user(user, raise_error=True) + + view_type = view_type_registry.get_by_model(original_view) + + storage = default_storage + + fields = original_view.table.field_set.all() + + files_buffer = BytesIO() + # Use export/import to duplicate the view easily + with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: + serialized = view_type.export_serialized(original_view, files_zip, storage) + + existing_view_names = View.objects.filter( + table_id=original_view.table.id + ).values_list("name", flat=True) + + # Change the name of the view + name = serialized["name"] + match = ending_number_regex.match(name) + if match: + name, _ = match.groups() + serialized["name"] = find_unused_name( + [name], existing_view_names, max_length=255 + ) + + # The new view must not be publicly shared + if "public" in serialized: + serialized["public"] = False + + id_mapping = { + "database_fields": {field.id: field.id for field in fields}, + "database_field_select_options": {}, + } + with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: + duplicated_view = view_type.import_serialized( + original_view.table, serialized, id_mapping, files_zip, storage + ) + + queryset = View.objects.filter(table_id=original_view.table.id) + view_ids = queryset.values_list("id", flat=True) + + ordered_ids = [] + for view_id in view_ids: + if view_id != duplicated_view.id: + ordered_ids.append(view_id) + if view_id == original_view.id: + ordered_ids.append(duplicated_view.id) + + View.order_objects(queryset, ordered_ids) + duplicated_view.refresh_from_db() + + view_created.send( + self, view=duplicated_view, user=user, type_name=view_type.type + ) + views_reordered.send( + self, table=original_view.table, order=ordered_ids, user=None + ) + + return duplicated_view + def update_view( self, user: AbstractUser, view: View, **data: Dict[str, Any] ) -> View: diff --git a/backend/src/baserow/contrib/database/views/view_types.py b/backend/src/baserow/contrib/database/views/view_types.py index 9ca0ab86d..e47a5c9eb 100644 --- a/backend/src/baserow/contrib/database/views/view_types.py +++ b/backend/src/baserow/contrib/database/views/view_types.py @@ -76,6 +76,7 @@ class GridViewType(ViewType): """ serialized = super().export_serialized(grid, files_zip, storage) + serialized["row_identifier_type"] = grid.row_identifier_type serialized_field_options = [] for field_option in grid.get_field_options(): @@ -329,6 +330,10 @@ class GalleryViewType(ViewType): """ serialized = super().export_serialized(gallery, files_zip, storage) + + if gallery.card_cover_image_field: + serialized["card_cover_image_field_id"] = gallery.card_cover_image_field.id + serialized_field_options = [] for field_option in gallery.get_field_options(): serialized_field_options.append( @@ -351,7 +356,14 @@ class GalleryViewType(ViewType): """ serialized_copy = serialized_values.copy() + + if serialized_copy.get("card_cover_image_field_id", None): + serialized_copy["card_cover_image_field_id"] = id_mapping[ + "database_fields" + ][serialized_copy["card_cover_image_field_id"]] + field_options = serialized_copy.pop("field_options") + gallery_view = super().import_serialized( table, serialized_copy, id_mapping, files_zip, storage ) diff --git a/backend/src/baserow/core/utils.py b/backend/src/baserow/core/utils.py index d71e2da37..67c3271fe 100644 --- a/backend/src/baserow/core/utils.py +++ b/backend/src/baserow/core/utils.py @@ -335,6 +335,68 @@ def remove_invalid_surrogate_characters(content: bytes) -> str: return re.sub(r"\\u(d|D)([a-z|A-Z|0-9]{3})", "", content.decode("utf-8", "ignore")) +def find_unused_name( + variants_to_try: Iterable[str], + existing_names: Iterable[str], + max_length: int = None, + suffix: str = " {0}", +): + """ + Finds an unused name among the existing names. If no names in the provided + variants_to_try list are available then the last name in that list will + have a number appended which ensures it is an available unique name. + Respects the maximally allowed name length. In case the variants_to_try + are longer than that, they will get truncated to the maximally allowed length. + + :param variants_to_try: An iterable of name variant we want to try. + :param existing_names: An iterable of all pre existing values. + :parm max_length: Set this value if you have a length limit to the new name. + :param suffix: The suffix you want to append to the name to avoid + duplicate. The string is going to be formated with a number. + :return: The first available unused name. + """ + + existing_names_set = set(existing_names) + + if max_length is not None: + variants_to_try = [item[0:max_length] for item in variants_to_try] + + remaining_names = set(variants_to_try) - existing_names_set + # Some variants to try remain, let's return the first one + if remaining_names: + # Loop over to ensure we maintain the ordering provided by + # variant_to_try, so we always return the first available name and + # not any. + for name in variants_to_try: + if name in remaining_names: + return name + + # None of the names in the param list are available, now using the last one lets + # append a number to the name until we find a free one. + original_name = variants_to_try[-1] + + i = 2 + while True: + suffix_to_append = suffix.format(i) + suffix_length = len(suffix_to_append) + length_of_original_name_with_suffix = len(original_name) + suffix_length + + # At this point we know, that the original_name can only + # be maximally the length of max_length. Therefore + # if the length_of_original_name_with_suffix is longer + # we can further truncate the name by the length of the + # suffix. + if max_length is not None and length_of_original_name_with_suffix > max_length: + name = f"{original_name[:-suffix_length]}{suffix_to_append}" + else: + name = f"{original_name}{suffix_to_append}" + + if name not in existing_names_set: + return name + + i += 1 + + def grouper(n: int, iterable: Iterable): """ Groups the iterable by `n` per chunk and yields it. diff --git a/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py b/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py index 7474a4c4a..f06657713 100644 --- a/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py +++ b/backend/tests/baserow/contrib/database/airtable/test_airtable_handler.py @@ -269,6 +269,7 @@ def test_to_baserow_database_export(): "type": "grid", "name": "Grid", "order": 1, + "row_identifier_type": "id", "filter_type": "AND", "filters_disabled": False, "filters": [], diff --git a/backend/tests/baserow/contrib/database/api/views/test_view_views.py b/backend/tests/baserow/contrib/database/api/views/test_view_views.py index 00d101989..46668c131 100644 --- a/backend/tests/baserow/contrib/database/api/views/test_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/test_view_views.py @@ -185,6 +185,70 @@ def test_delete_view(api_client, data_fixture): assert GridView.objects.all().count() == 1 +@pytest.mark.django_db +def test_duplicate_views(api_client, data_fixture): + user, token = data_fixture.create_user_and_token( + email="test@test.nl", password="password", first_name="Test1" + ) + table_1 = data_fixture.create_database_table(user=user) + field = data_fixture.create_text_field(table=table_1) + table_2 = data_fixture.create_database_table() + view_1 = data_fixture.create_grid_view(table=table_1, order=1) + view_2 = data_fixture.create_grid_view(table=table_2, order=2) + view_3 = data_fixture.create_grid_view(table=table_1, order=3) + + field_option = data_fixture.create_grid_view_field_option( + grid_view=view_1, + field=field, + aggregation_type="whatever", + aggregation_raw_type="empty", + ) + view_filter = data_fixture.create_view_filter( + view=view_1, field=field, value="test", type="equal" + ) + view_sort = data_fixture.create_view_sort(view=view_1, field=field, order="ASC") + + view_decoration = data_fixture.create_view_decoration( + view=view_1, + value_provider_conf={"config": 12}, + ) + + response = api_client.post( + reverse("api:database:views:duplicate", kwargs={"view_id": view_2.id}), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP" + + response = api_client.post( + reverse("api:database:views:duplicate", kwargs={"view_id": 999999}), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST" + + assert View.objects.count() == 3 + + response = api_client.post( + reverse("api:database:views:duplicate", kwargs={"view_id": view_1.id}), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK + + assert View.objects.count() == 4 + + assert response_json["id"] != view_1.id + assert response_json["order"] == view_1.order + 1 + assert "sortings" in response_json + assert "filters" in response_json + assert "decorations" in response_json + + @pytest.mark.django_db def test_order_views(api_client, data_fixture): user, token = data_fixture.create_user_and_token( diff --git a/backend/tests/baserow/contrib/database/view/actions/test_view_duplicate_actions.py b/backend/tests/baserow/contrib/database/view/actions/test_view_duplicate_actions.py new file mode 100644 index 000000000..243b25007 --- /dev/null +++ b/backend/tests/baserow/contrib/database/view/actions/test_view_duplicate_actions.py @@ -0,0 +1,45 @@ +import pytest + +from baserow.contrib.database.views.actions import DuplicateViewActionType +from baserow.contrib.database.action.scopes import TableActionScopeType +from baserow.contrib.database.views.models import View +from baserow.core.action.handler import ActionHandler +from baserow.core.action.registries import action_type_registry + + +@pytest.mark.django_db +def test_can_undo_duplicate_view(data_fixture): + session_id = "session-id" + user = data_fixture.create_user(session_id=session_id) + table = data_fixture.create_database_table(user) + grid_view = data_fixture.create_grid_view(table=table) + + new_view = action_type_registry.get_by_type(DuplicateViewActionType).do( + user, grid_view + ) + + assert View.objects.count() == 2 + + ActionHandler.undo(user, [TableActionScopeType.value(table.id)], session_id) + + assert View.objects.count() == 1 + + +@pytest.mark.django_db +def test_can_undo_redo_create_view(data_fixture): + session_id = "session-id" + user = data_fixture.create_user(session_id=session_id) + table = data_fixture.create_database_table(user) + grid_view = data_fixture.create_grid_view(table=table) + + action_type_registry.get_by_type(DuplicateViewActionType).do(user, grid_view) + + assert View.objects.count() == 2 + + ActionHandler.undo(user, [TableActionScopeType.value(table.id)], session_id) + + assert View.objects.count() == 1 + + ActionHandler.redo(user, [TableActionScopeType.value(table.id)], session_id) + + assert View.objects.count() == 2 diff --git a/backend/tests/baserow/contrib/database/view/test_view_handler.py b/backend/tests/baserow/contrib/database/view/test_view_handler.py index 160270602..65713d1c5 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_handler.py +++ b/backend/tests/baserow/contrib/database/view/test_view_handler.py @@ -376,6 +376,65 @@ def test_delete_form_view(send_mock, data_fixture): assert send_mock.call_args[1]["user"].id == user.id +@pytest.mark.django_db +@patch("baserow.contrib.database.views.signals.view_created.send") +@patch("baserow.contrib.database.views.signals.views_reordered.send") +def test_duplicate_views(reordered_mock, created_mock, data_fixture): + user = data_fixture.create_user() + user_2 = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + field = data_fixture.create_text_field(table=table) + grid = data_fixture.create_public_password_protected_grid_view(table=table, order=1) + # Add another view to challenge the insertion position of the duplicate + form = data_fixture.create_form_view(table=table, order=2) + + field_option = data_fixture.create_grid_view_field_option( + grid_view=grid, + field=field, + aggregation_type="whatever", + aggregation_raw_type="empty", + ) + view_filter = data_fixture.create_view_filter( + view=grid, field=field, value="test", type="equal" + ) + view_sort = data_fixture.create_view_sort(view=grid, field=field, order="ASC") + + view_decoration = data_fixture.create_view_decoration( + view=grid, + value_provider_conf={"config": 12}, + ) + + handler = ViewHandler() + + with pytest.raises(UserNotInGroup): + handler.duplicate_view(user=user_2, original_view=grid) + + new_view = handler.duplicate_view(user=user, original_view=grid) + + created_mock.assert_called_once() + assert created_mock.call_args[1]["view"].id == new_view.id + assert created_mock.call_args[1]["user"].id == user.id + + reordered_mock.assert_called_once() + assert reordered_mock.call_args[1]["order"] == [grid.id, new_view.id, form.id] + + grid.refresh_from_db() + assert new_view.name == grid.name + " 2" + assert new_view.id != grid.id + assert new_view.order == grid.order + 1 + assert new_view.public is False + assert new_view.viewfilter_set.all().first().value == view_filter.value + assert new_view.viewsort_set.all().first().order == view_sort.order + assert ( + new_view.viewdecoration_set.all()[0].value_provider_conf + == view_decoration.value_provider_conf + ) + + new_view2 = handler.duplicate_view(user=user, original_view=new_view) + + assert new_view2.name == grid.name + " 3" + + @pytest.mark.django_db @patch("baserow.contrib.database.views.signals.views_reordered.send") def test_order_views(send_mock, data_fixture): diff --git a/backend/tests/baserow/contrib/database/view/test_view_types.py b/backend/tests/baserow/contrib/database/view/test_view_types.py index 43f39ec18..2c06815d7 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_types.py +++ b/backend/tests/baserow/contrib/database/view/test_view_types.py @@ -19,7 +19,11 @@ from baserow.contrib.database.fields.handler import FieldHandler @pytest.mark.django_db def test_import_export_grid_view(data_fixture): grid_view = data_fixture.create_grid_view( - name="Test", order=1, filter_type="AND", filters_disabled=False + name="Test", + order=1, + filter_type="AND", + filters_disabled=False, + row_identifier_type="count", ) field = data_fixture.create_text_field(table=grid_view.table) imported_field = data_fixture.create_text_field(table=grid_view.table) @@ -52,6 +56,7 @@ def test_import_export_grid_view(data_fixture): assert grid_view.order == imported_grid_view.order assert grid_view.filter_type == imported_grid_view.filter_type assert grid_view.filters_disabled == imported_grid_view.filters_disabled + assert grid_view.row_identifier_type == imported_grid_view.row_identifier_type assert imported_grid_view.viewfilter_set.all().count() == 1 assert imported_grid_view.viewsort_set.all().count() == 1 @@ -148,7 +153,10 @@ def test_import_export_gallery_view(data_fixture, tmpdir): storage = FileSystemStorage(location=str(tmpdir), base_url="http://localhost") table = data_fixture.create_database_table(user=user) - gallery_view = data_fixture.create_gallery_view(table=table) + file_field = data_fixture.create_file_field(table=table) + gallery_view = data_fixture.create_gallery_view( + table=table, card_cover_image_field=file_field + ) text_field = data_fixture.create_text_field(table=table) field_option = data_fixture.create_gallery_view_field_option( gallery_view, text_field, order=1 @@ -166,14 +174,21 @@ def test_import_export_gallery_view(data_fixture, tmpdir): assert serialized["type"] == "gallery" assert serialized["name"] == gallery_view.name assert serialized["order"] == 0 - assert len(serialized["field_options"]) == 1 + assert serialized["card_cover_image_field_id"] == file_field.id + assert len(serialized["field_options"]) == 2 assert serialized["field_options"][0]["id"] == field_option.id assert serialized["field_options"][0]["field_id"] == field_option.field_id assert serialized["field_options"][0]["hidden"] is True assert serialized["field_options"][0]["order"] == 1 imported_single_select_field = data_fixture.create_text_field(table=table) - id_mapping = {"database_fields": {text_field.id: imported_single_select_field.id}} + imported_file_field = data_fixture.create_file_field(table=table) + id_mapping = { + "database_fields": { + text_field.id: imported_single_select_field.id, + file_field.id: imported_file_field.id, + } + } with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: imported_gallery_view = gallery_view_type.import_serialized( @@ -183,8 +198,9 @@ def test_import_export_gallery_view(data_fixture, tmpdir): assert gallery_view.id != imported_gallery_view.id assert gallery_view.name == imported_gallery_view.name assert gallery_view.order == imported_gallery_view.order + assert imported_gallery_view.card_cover_image_field.id == imported_file_field.id imported_field_options = imported_gallery_view.get_field_options() - assert len(imported_field_options) == 1 + assert len(imported_field_options) == 2 imported_field_option = imported_field_options[0] assert field_option.id != imported_field_option.id assert field_option.hidden == imported_field_option.hidden diff --git a/backend/tests/baserow/core/test_core_utils.py b/backend/tests/baserow/core/test_core_utils.py index d8330ccc2..82e82b0cd 100644 --- a/backend/tests/baserow/core/test_core_utils.py +++ b/backend/tests/baserow/core/test_core_utils.py @@ -16,6 +16,7 @@ from baserow.core.utils import ( truncate_middle, split_comma_separated_string, remove_invalid_surrogate_characters, + find_unused_name, grouper, Progress, ChildProgressBuilder, @@ -113,6 +114,88 @@ def test_remove_invalid_surrogate_characters(): assert remove_invalid_surrogate_characters(b"test\uD83Dtest") == "testtest" +def test_unused_names(): + assert find_unused_name(["test"], ["foo", "bar", "baz"]) == "test" + assert find_unused_name(["test"], ["test", "field", "field 2"]) == "test 2" + assert find_unused_name(["test", "other"], ["test", "field", "field 2"]) == "other" + assert find_unused_name(["field"], ["test", "field", "field 2"]) == "field 3" + assert find_unused_name(["field"], [1, 2]) == "field" + assert ( + find_unused_name( + ["regex like field [0-9]"], + ["regex like field [0-9]", "regex like field [0-9] 2"], + ) + == "regex like field [0-9] 3" + ) + # Try another suffix + assert ( + find_unused_name( + ["field"], ["field", "field 4" "field (1)", "field (2)"], suffix=" ({0})" + ) + == "field (3)" + ) + + +def test_unused_names_with_max_length(): + max_name_length = 255 + exactly_length_field_name = "x" * max_name_length + too_long_field_name = "x" * (max_name_length + 1) + + # Make sure that the returned string does not exceed the max_name_length + assert ( + len( + find_unused_name( + [exactly_length_field_name], [], max_length=max_name_length + ) + ) + <= max_name_length + ) + assert ( + len( + find_unused_name( + [f"{exactly_length_field_name} - test"], [], max_length=max_name_length + ) + ) + <= max_name_length + ) + assert ( + len(find_unused_name([too_long_field_name], [], max_length=max_name_length)) + <= max_name_length + ) + + initial_name = ( + "xIyV4w3J4J0Zzd5ZIz4eNPucQOa9tS25ULHw2SCr4RDZ9h2AvxYr5nlGRNQR2ir517B3SkZB" + "nw2eGnBJQAdX8A6QcSCmcbBAnG3BczFytJkHJK7cE6VsAS6tROTg7GOwSQsdImURRwEarrXo" + "lv9H4bylyJM0bDPkgB4H6apiugZ19X0C9Fw2ed125MJHoFgTZLbJRc6joNyJSOkGkmGhBuIq" + "RKipRYGzB4oiFKYPx5Xoc8KHTsLqVDQTWwwzhaR" + ) + expected_name_1 = ( + "xIyV4w3J4J0Zzd5ZIz4eNPucQOa9tS25ULHw2SCr4RDZ9h2AvxYr5nlGRNQR2ir517B3SkZB" + "nw2eGnBJQAdX8A6QcSCmcbBAnG3BczFytJkHJK7cE6VsAS6tROTg7GOwSQsdImURRwEarrXo" + "lv9H4bylyJM0bDPkgB4H6apiugZ19X0C9Fw2ed125MJHoFgTZLbJRc6joNyJSOkGkmGhBuIq" + "RKipRYGzB4oiFKYPx5Xoc8KHTsLqVDQTWwwzh 2" + ) + + expected_name_2 = ( + "xIyV4w3J4J0Zzd5ZIz4eNPucQOa9tS25ULHw2SCr4RDZ9h2AvxYr5nlGRNQR2ir517B3SkZB" + "nw2eGnBJQAdX8A6QcSCmcbBAnG3BczFytJkHJK7cE6VsAS6tROTg7GOwSQsdImURRwEarrXo" + "lv9H4bylyJM0bDPkgB4H6apiugZ19X0C9Fw2ed125MJHoFgTZLbJRc6joNyJSOkGkmGhBuIq" + "RKipRYGzB4oiFKYPx5Xoc8KHTsLqVDQTWwwzh 3" + ) + + assert ( + find_unused_name([initial_name], [initial_name], max_length=max_name_length) + == expected_name_1 + ) + + assert ( + find_unused_name( + [initial_name], [initial_name, expected_name_1], max_length=max_name_length + ) + == expected_name_2 + ) + + def test_grouper(): assert list(grouper(2, [1, 2, 3, 4, 5])) == [(1, 2), (3, 4), (5,)] diff --git a/changelog.md b/changelog.md index 6764f8c7b..50babdbda 100644 --- a/changelog.md +++ b/changelog.md @@ -20,6 +20,7 @@ For example: * Added multi-cell clearing via backspace key (delete on Mac). * Added API exception registry that allows plugins to provide custom exception mappings for the REST API. * Added formula round and int functions. [#891](https://gitlab.com/bramw/baserow/-/issues/891) +* Views can be duplicated. [#962](https://gitlab.com/bramw/baserow/-/issues/962) ### Bug Fixes diff --git a/premium/backend/src/baserow_premium/views/view_types.py b/premium/backend/src/baserow_premium/views/view_types.py index 197034dcf..40790340e 100644 --- a/premium/backend/src/baserow_premium/views/view_types.py +++ b/premium/backend/src/baserow_premium/views/view_types.py @@ -103,7 +103,11 @@ class KanbanViewType(ViewType): """ serialized = super().export_serialized(kanban, files_zip, storage) - serialized["single_select_field_id"] = kanban.single_select_field_id + if kanban.single_select_field_id: + serialized["single_select_field_id"] = kanban.single_select_field_id + + if kanban.card_cover_image_field_id: + serialized["card_cover_image_field_id"] = kanban.card_cover_image_field_id serialized_field_options = [] for field_option in kanban.get_field_options(): @@ -132,9 +136,16 @@ class KanbanViewType(ViewType): """ serialized_copy = serialized_values.copy() - serialized_copy["single_select_field_id"] = id_mapping["database_fields"][ - serialized_copy.pop("single_select_field_id") - ] + if "single_select_field_id" in serialized_copy: + serialized_copy["single_select_field_id"] = id_mapping["database_fields"][ + serialized_copy.pop("single_select_field_id") + ] + + if "card_cover_image_field_id" in serialized_copy: + serialized_copy["card_cover_image_field_id"] = id_mapping[ + "database_fields" + ][serialized_copy.pop("card_cover_image_field_id")] + field_options = serialized_copy.pop("field_options") kanban_view = super().import_serialized( table, serialized_copy, id_mapping, files_zip, storage diff --git a/premium/backend/tests/baserow_premium/views/test_premium_view_types.py b/premium/backend/tests/baserow_premium/views/test_premium_view_types.py index b8202b49f..0cf07916f 100644 --- a/premium/backend/tests/baserow_premium/views/test_premium_view_types.py +++ b/premium/backend/tests/baserow_premium/views/test_premium_view_types.py @@ -72,15 +72,16 @@ def test_import_export_kanban_view(premium_data_fixture, tmpdir): storage = FileSystemStorage(location=str(tmpdir), base_url="http://localhost") table = premium_data_fixture.create_database_table(user=user) + file_field = premium_data_fixture.create_file_field(table=table) kanban_view = premium_data_fixture.create_kanban_view( - table=table, - single_select_field=None, + table=table, single_select_field=None, card_cover_image_field=file_field ) single_select_field = premium_data_fixture.create_single_select_field(table=table) field_option = premium_data_fixture.create_kanban_view_field_option( kanban_view=kanban_view, field=single_select_field, hidden=True, order=1 ) kanban_view.single_select_field = single_select_field + kanban_view.save() files_buffer = BytesIO() kanban_field_type = view_type_registry.get("kanban") @@ -95,7 +96,8 @@ def test_import_export_kanban_view(premium_data_fixture, tmpdir): assert serialized["name"] == kanban_view.name assert serialized["order"] == 0 assert serialized["single_select_field_id"] == single_select_field.id - assert len(serialized["field_options"]) == 1 + assert serialized["card_cover_image_field_id"] == file_field.id + assert len(serialized["field_options"]) == 2 assert serialized["field_options"][0]["id"] == field_option.id assert serialized["field_options"][0]["field_id"] == field_option.field_id assert serialized["field_options"][0]["hidden"] is True @@ -104,9 +106,13 @@ def test_import_export_kanban_view(premium_data_fixture, tmpdir): imported_single_select_field = premium_data_fixture.create_single_select_field( table=table ) + imported_file_field = premium_data_fixture.create_file_field(table=table) id_mapping = { - "database_fields": {single_select_field.id: imported_single_select_field.id} + "database_fields": { + single_select_field.id: imported_single_select_field.id, + file_field.id: imported_file_field.id, + } } with ZipFile(files_buffer, "a", ZIP_DEFLATED, False) as files_zip: @@ -120,9 +126,14 @@ def test_import_export_kanban_view(premium_data_fixture, tmpdir): assert ( kanban_view.single_select_field_id != imported_kanban_view.single_select_field ) + assert ( + kanban_view.card_cover_image_field_id + != imported_kanban_view.card_cover_image_field_id + ) + assert imported_kanban_view.card_cover_image_field_id == imported_file_field.id imported_field_options = imported_kanban_view.get_field_options() - assert len(imported_field_options) == 1 + assert len(imported_field_options) == 2 imported_field_option = imported_field_options[0] assert field_option.id != imported_field_option.id assert imported_single_select_field.id == imported_field_option.field_id diff --git a/web-frontend/modules/database/components/view/ViewContext.vue b/web-frontend/modules/database/components/view/ViewContext.vue index c86ada154..51b69d3fb 100644 --- a/web-frontend/modules/database/components/view/ViewContext.vue +++ b/web-frontend/modules/database/components/view/ViewContext.vue @@ -7,6 +7,12 @@ {{ $t('viewContext.exportView') }} </a> </li> + <li> + <a @click="duplicateView()"> + <i class="context__menu-icon fas fa-fw fa-clone"></i> + {{ $t('viewContext.duplicateView') }} + </a> + </li> <li> <a @click="openWebhookModal()"> <i class="context__menu-icon fas fa-fw fa-globe"></i> @@ -86,6 +92,29 @@ export default { this.setLoading(this.view, false) }, + async duplicateView() { + this.setLoading(this.view, true) + let newView + + try { + newView = await this.$store.dispatch('view/duplicate', this.view) + } catch (error) { + this.handleError(error, 'view') + } + + this.$refs.context.hide() + this.setLoading(this.view, false) + + // Redirect to the newly created view. + this.$nuxt.$router.push({ + name: 'database-table', + params: { + databaseId: this.table.database_id, + tableId: this.table.id, + viewId: newView.id, + }, + }) + }, exportView() { this.$refs.context.hide() this.$refs.exportViewModal.show() diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json index 33cad15f7..489317d5d 100644 --- a/web-frontend/modules/database/locales/en.json +++ b/web-frontend/modules/database/locales/en.json @@ -451,6 +451,7 @@ }, "viewContext": { "exportView": "Export view", + "duplicateView": "Duplicate view", "renameView": "Rename view", "webhooks": "Webhooks", "deleteView": "Delete view" diff --git a/web-frontend/modules/database/services/view.js b/web-frontend/modules/database/services/view.js index 1350f0845..25b065ac1 100644 --- a/web-frontend/modules/database/services/view.js +++ b/web-frontend/modules/database/services/view.js @@ -62,6 +62,9 @@ export default (client) => { update(viewId, values) { return client.patch(`/database/views/${viewId}/`, values) }, + duplicate(viewId) { + return client.post(`/database/views/${viewId}/duplicate/`) + }, order(tableId, order) { return client.post(`/database/views/table/${tableId}/order/`, { view_ids: order, diff --git a/web-frontend/modules/database/store/view.js b/web-frontend/modules/database/store/view.js index 00b4fc098..d6b87e45b 100644 --- a/web-frontend/modules/database/store/view.js +++ b/web-frontend/modules/database/store/view.js @@ -85,7 +85,7 @@ export const mutations = { view._.loading = value }, ADD_ITEM(state, item) { - state.items.push(item) + state.items = [...state.items, item].sort((a, b) => a.order - b.order) }, UPDATE_ITEM(state, { id, values }) { const index = state.items.findIndex((item) => item.id === id) @@ -328,6 +328,14 @@ export const actions = { forceUpdate({ commit }, { view, values }) { commit('UPDATE_ITEM', { id: view.id, values }) }, + /** + * Duplicates an existing view. + */ + async duplicate({ commit, dispatch }, view) { + const { data } = await ViewService(this.$client).duplicate(view.id) + await dispatch('forceCreate', { data }) + return data + }, /** * Deletes an existing view with the provided id. A request to the server is first * made and after that it will be deleted from the store.