mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-11 07:51:20 +00:00
Resolve "Make the views orderable"
This commit is contained in:
parent
79a77cad4d
commit
6571ef2e2c
29 changed files with 634 additions and 14 deletions
backend
src/baserow
api/groups
contrib/database
api/views
views
ws
core
tests/baserow/contrib/database
web-frontend/modules
core
assets/scss/components
components/group
directives
plugins
services
store
database
|
@ -185,6 +185,7 @@ class GroupOrderView(APIView):
|
|||
},
|
||||
)
|
||||
@validate_body(OrderGroupsSerializer)
|
||||
@transaction.atomic
|
||||
def post(self, request, data):
|
||||
"""Updates to order of some groups for a user."""
|
||||
|
||||
|
|
|
@ -6,6 +6,11 @@ ERROR_VIEW_DOES_NOT_EXIST = (
|
|||
HTTP_404_NOT_FOUND,
|
||||
"The requested view does not exist.",
|
||||
)
|
||||
ERROR_VIEW_NOT_IN_TABLE = (
|
||||
"ERROR_VIEW_NOT_IN_TABLE",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
"The view id {e.view_id} does not belong to the table.",
|
||||
)
|
||||
ERROR_VIEW_FILTER_DOES_NOT_EXIST = (
|
||||
"ERROR_VIEW_FILTER_DOES_NOT_EXIST",
|
||||
HTTP_404_NOT_FOUND,
|
||||
|
|
|
@ -137,3 +137,9 @@ class UpdateViewSerializer(serializers.ModelSerializer):
|
|||
"filter_type": {"required": False},
|
||||
"filters_disabled": {"required": False},
|
||||
}
|
||||
|
||||
|
||||
class OrderViewsSerializer(serializers.Serializer):
|
||||
view_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="View ids in the desired order."
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ from baserow.contrib.database.views.registries import view_type_registry
|
|||
from .views import (
|
||||
ViewsView,
|
||||
ViewView,
|
||||
OrderViewsView,
|
||||
ViewFiltersView,
|
||||
ViewFilterView,
|
||||
ViewSortingsView,
|
||||
|
@ -16,6 +17,7 @@ app_name = "baserow.contrib.database.api.views"
|
|||
|
||||
urlpatterns = view_type_registry.api_urls + [
|
||||
url(r"table/(?P<table_id>[0-9]+)/$", ViewsView.as_view(), name="list"),
|
||||
url(r"table/(?P<table_id>[0-9]+)/order/$", OrderViewsView.as_view(), name="order"),
|
||||
url(
|
||||
r"filter/(?P<view_filter_id>[0-9]+)/$",
|
||||
ViewFilterView.as_view(),
|
||||
|
|
|
@ -29,6 +29,7 @@ from baserow.contrib.database.views.models import View, ViewFilter, ViewSort
|
|||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewDoesNotExist,
|
||||
ViewNotInTable,
|
||||
ViewFilterDoesNotExist,
|
||||
ViewFilterNotSupported,
|
||||
ViewFilterTypeNotAllowedForField,
|
||||
|
@ -42,6 +43,7 @@ from .serializers import (
|
|||
ViewSerializer,
|
||||
CreateViewSerializer,
|
||||
UpdateViewSerializer,
|
||||
OrderViewsSerializer,
|
||||
ViewFilterSerializer,
|
||||
CreateViewFilterSerializer,
|
||||
UpdateViewFilterSerializer,
|
||||
|
@ -51,6 +53,7 @@ from .serializers import (
|
|||
)
|
||||
from .errors import (
|
||||
ERROR_VIEW_DOES_NOT_EXIST,
|
||||
ERROR_VIEW_NOT_IN_TABLE,
|
||||
ERROR_VIEW_FILTER_DOES_NOT_EXIST,
|
||||
ERROR_VIEW_FILTER_NOT_SUPPORTED,
|
||||
ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
|
||||
|
@ -377,6 +380,53 @@ class ViewView(APIView):
|
|||
return Response(status=204)
|
||||
|
||||
|
||||
class OrderViewsView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="table_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Updates the order of the views in the table related to "
|
||||
"the provided value.",
|
||||
),
|
||||
],
|
||||
tags=["Database table views"],
|
||||
operation_id="order_database_table_views",
|
||||
description=(
|
||||
"Changes the order of the provided view ids to the matching position that "
|
||||
"the id has in the list. If the authorized user does not belong to the "
|
||||
"group it will be ignored. The order of the not provided views will be "
|
||||
"set to `0`."
|
||||
),
|
||||
request=OrderViewsSerializer,
|
||||
responses={
|
||||
204: None,
|
||||
400: get_error_schema(
|
||||
["ERROR_USER_NOT_IN_GROUP", "ERROR_VIEW_NOT_IN_TABLE"]
|
||||
),
|
||||
404: get_error_schema(["ERROR_TABLE_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@validate_body(OrderViewsSerializer)
|
||||
@transaction.atomic
|
||||
@map_exceptions(
|
||||
{
|
||||
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
|
||||
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
|
||||
ViewNotInTable: ERROR_VIEW_NOT_IN_TABLE,
|
||||
}
|
||||
)
|
||||
def post(self, request, data, table_id):
|
||||
"""Updates to order of the views in a table."""
|
||||
|
||||
table = TableHandler().get_table(table_id)
|
||||
ViewHandler().order_views(request.user, table, data["view_ids"])
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class ViewFiltersView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
|
|
|
@ -8,6 +8,18 @@ class ViewDoesNotExist(Exception):
|
|||
"""Raised when trying to get a view that doesn't exist."""
|
||||
|
||||
|
||||
class ViewNotInTable(Exception):
|
||||
"""Raised when a provided view does not belong to a table."""
|
||||
|
||||
def __init__(self, view_id=None, *args, **kwargs):
|
||||
self.view_id = view_id
|
||||
super().__init__(
|
||||
f"The view {view_id} does not belong to the table.",
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class UnrelatedFieldError(Exception):
|
||||
"""
|
||||
Raised when a field is not related to the view. For example when someone tries to
|
||||
|
|
|
@ -6,6 +6,7 @@ from baserow.contrib.database.fields.registries import field_type_registry
|
|||
from baserow.core.utils import extract_allowed, set_allowed_attrs
|
||||
from .exceptions import (
|
||||
ViewDoesNotExist,
|
||||
ViewNotInTable,
|
||||
UnrelatedFieldError,
|
||||
ViewFilterDoesNotExist,
|
||||
ViewFilterNotSupported,
|
||||
|
@ -21,6 +22,7 @@ from .signals import (
|
|||
view_created,
|
||||
view_updated,
|
||||
view_deleted,
|
||||
views_reordered,
|
||||
view_filter_created,
|
||||
view_filter_updated,
|
||||
view_filter_deleted,
|
||||
|
@ -140,6 +142,34 @@ class ViewHandler:
|
|||
|
||||
return view
|
||||
|
||||
def order_views(self, user, table, order):
|
||||
"""
|
||||
Updates the order of the views in the given table. The order of the views
|
||||
that are not in the `order` parameter set set to `0`.
|
||||
|
||||
:param user: The user on whose behalf the views are ordered.
|
||||
:type user: User
|
||||
:param table: The table of which the views must be updated.
|
||||
:type table: Table
|
||||
:param order: A list containing the view ids in the desired order.
|
||||
:type order: list
|
||||
:raises ViewNotInTable: If one of the view ids in the order does not belong
|
||||
to the table.
|
||||
"""
|
||||
|
||||
group = table.database.group
|
||||
group.has_user(user, raise_error=True)
|
||||
|
||||
queryset = View.objects.filter(table_id=table.id)
|
||||
view_ids = queryset.values_list("id", flat=True)
|
||||
|
||||
for view_id in order:
|
||||
if view_id not in view_ids:
|
||||
raise ViewNotInTable(view_id)
|
||||
|
||||
View.order_objects(queryset, order)
|
||||
views_reordered.send(self, table=table, order=order, user=user)
|
||||
|
||||
def delete_view(self, user, view):
|
||||
"""
|
||||
Deletes an existing view instance.
|
||||
|
|
|
@ -4,6 +4,7 @@ from django.dispatch import Signal
|
|||
view_created = Signal()
|
||||
view_updated = Signal()
|
||||
view_deleted = Signal()
|
||||
views_reordered = Signal()
|
||||
|
||||
view_filter_created = Signal()
|
||||
view_filter_updated = Signal()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from .table.signals import table_created, table_updated, table_deleted
|
||||
from .views.signals import view_created, view_updated, view_deleted
|
||||
from .views.signals import view_created, views_reordered, view_updated, view_deleted
|
||||
from .rows.signals import row_created, row_updated, row_deleted
|
||||
from .fields.signals import field_created, field_updated, field_deleted
|
||||
|
||||
|
@ -8,6 +8,7 @@ __all__ = [
|
|||
"table_created",
|
||||
"table_updated",
|
||||
"table_deleted",
|
||||
"views_reordered",
|
||||
"view_created",
|
||||
"view_updated",
|
||||
"view_deleted",
|
||||
|
|
|
@ -66,6 +66,18 @@ def view_deleted(sender, view_id, view, user, **kwargs):
|
|||
)
|
||||
|
||||
|
||||
@receiver(view_signals.views_reordered)
|
||||
def views_reordered(sender, table, order, user, **kwargs):
|
||||
table_page_type = page_registry.get("table")
|
||||
transaction.on_commit(
|
||||
lambda: table_page_type.broadcast(
|
||||
{"type": "views_reordered", "table_id": table.id, "order": order},
|
||||
getattr(user, "web_socket_id", None),
|
||||
table_id=table.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@receiver(view_signals.view_filter_created)
|
||||
def view_filter_created(sender, view_filter, user, **kwargs):
|
||||
table_page_type = page_registry.get("table")
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.db import models
|
||||
from django.db.models import Case, When, Value
|
||||
from django.db.models.fields import NOT_PROVIDED
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.functional import cached_property
|
||||
|
@ -24,6 +25,34 @@ class OrderableMixin:
|
|||
|
||||
return queryset.aggregate(models.Max(field)).get(f"{field}__max", 0) or 0
|
||||
|
||||
@classmethod
|
||||
def order_objects(cls, queryset, order, field="order"):
|
||||
"""
|
||||
Changes the order of the objects in the given queryset to the desired order
|
||||
provided in the order parameter.
|
||||
|
||||
:param queryset: The queryset of the objects that need to be updated.
|
||||
:type queryset: QuerySet
|
||||
:param order: A list containing the object ids in the desired order.
|
||||
:type order: list
|
||||
:param field: The name of the order column/field.
|
||||
:type field: str
|
||||
:return: The amount of objects updated.
|
||||
:rtype: int
|
||||
"""
|
||||
|
||||
return queryset.update(
|
||||
**{
|
||||
field: Case(
|
||||
*[
|
||||
When(id=id, then=Value(index + 1))
|
||||
for index, id in enumerate(order)
|
||||
],
|
||||
default=Value(0),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PolymorphicContentTypeMixin:
|
||||
"""
|
||||
|
|
|
@ -2,6 +2,7 @@ import pytest
|
|||
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_204_NO_CONTENT,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_401_UNAUTHORIZED,
|
||||
HTTP_404_NOT_FOUND,
|
||||
|
@ -441,6 +442,69 @@ def test_delete_view(api_client, data_fixture):
|
|||
assert GridView.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_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)
|
||||
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_1, order=2)
|
||||
view_3 = data_fixture.create_grid_view(table=table_1, order=3)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:order", kwargs={"table_id": table_2.id}),
|
||||
{"view_ids": []},
|
||||
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:order", kwargs={"table_id": 999999}),
|
||||
{"view_ids": []},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:order", kwargs={"table_id": table_1.id}),
|
||||
{"view_ids": [0]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_VIEW_NOT_IN_TABLE"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:order", kwargs={"table_id": table_1.id}),
|
||||
{"view_ids": ["test"]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:order", kwargs={"table_id": table_1.id}),
|
||||
{"view_ids": [view_3.id, view_2.id, view_1.id]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
|
||||
view_1.refresh_from_db()
|
||||
view_2.refresh_from_db()
|
||||
view_3.refresh_from_db()
|
||||
assert view_1.order == 3
|
||||
assert view_2.order == 2
|
||||
assert view_3.order == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_view_filters(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
|
|
|
@ -12,6 +12,7 @@ from baserow.contrib.database.views.registries import (
|
|||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewTypeDoesNotExist,
|
||||
ViewDoesNotExist,
|
||||
ViewNotInTable,
|
||||
UnrelatedFieldError,
|
||||
ViewFilterDoesNotExist,
|
||||
ViewFilterNotSupported,
|
||||
|
@ -166,6 +167,54 @@ def test_update_view(send_mock, data_fixture):
|
|||
assert grid.filters_disabled
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.views_reordered.send")
|
||||
def test_order_views(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
grid_1 = data_fixture.create_grid_view(table=table, order=1)
|
||||
grid_2 = data_fixture.create_grid_view(table=table, order=2)
|
||||
grid_3 = data_fixture.create_grid_view(table=table, order=3)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroup):
|
||||
handler.order_views(user=user_2, table=table, order=[])
|
||||
|
||||
with pytest.raises(ViewNotInTable):
|
||||
handler.order_views(user=user, table=table, order=[0])
|
||||
|
||||
handler.order_views(user=user, table=table, order=[grid_3.id, grid_2.id, grid_1.id])
|
||||
grid_1.refresh_from_db()
|
||||
grid_2.refresh_from_db()
|
||||
grid_3.refresh_from_db()
|
||||
assert grid_1.order == 3
|
||||
assert grid_2.order == 2
|
||||
assert grid_3.order == 1
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["table"].id == table.id
|
||||
assert send_mock.call_args[1]["user"].id == user.id
|
||||
assert send_mock.call_args[1]["order"] == [grid_3.id, grid_2.id, grid_1.id]
|
||||
|
||||
handler.order_views(user=user, table=table, order=[grid_1.id, grid_3.id, grid_2.id])
|
||||
grid_1.refresh_from_db()
|
||||
grid_2.refresh_from_db()
|
||||
grid_3.refresh_from_db()
|
||||
assert grid_1.order == 1
|
||||
assert grid_2.order == 3
|
||||
assert grid_3.order == 2
|
||||
|
||||
handler.order_views(user=user, table=table, order=[grid_1.id])
|
||||
grid_1.refresh_from_db()
|
||||
grid_2.refresh_from_db()
|
||||
grid_3.refresh_from_db()
|
||||
assert grid_1.order == 1
|
||||
assert grid_2.order == 0
|
||||
assert grid_3.order == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_deleted.send")
|
||||
def test_delete_view(send_mock, data_fixture):
|
||||
|
|
|
@ -57,6 +57,21 @@ def test_view_deleted(mock_broadcast_to_channel_group, data_fixture):
|
|||
assert args[0][1]["table_id"] == table_id
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@patch("baserow.ws.registries.broadcast_to_channel_group")
|
||||
def test_views_reordered(mock_broadcast_to_channel_group, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
view = data_fixture.create_grid_view(user=user)
|
||||
ViewHandler().order_views(user=user, table=view.table, order=[view.id])
|
||||
|
||||
mock_broadcast_to_channel_group.delay.assert_called_once()
|
||||
args = mock_broadcast_to_channel_group.delay.call_args
|
||||
assert args[0][0] == f"table-{view.table.id}"
|
||||
assert args[0][1]["type"] == "views_reordered"
|
||||
assert args[0][1]["table_id"] == view.table.id
|
||||
assert args[0][1]["order"] == [view.id]
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@patch("baserow.ws.registries.broadcast_to_channel_group")
|
||||
def test_view_filter_created(mock_broadcast_to_channel_group, data_fixture):
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
* Fixed bug where the grid view would fail hard if a cell is selected and the component
|
||||
is destroyed.
|
||||
* Made it possible to order the views by drag and drop.
|
||||
* Made it possible to order the groups by drag and drop.
|
||||
|
||||
## Released (2021-05-11)
|
||||
|
||||
|
@ -20,6 +22,7 @@
|
|||
* Fixed bug where the rows could get out of sync during real time collaboration.
|
||||
* Made it possible to export and import the file field including contents.
|
||||
* Added `fill_users` admin management command which fills baserow with fake users.
|
||||
* Made it possible to drag and drop the views in the desired order.
|
||||
* **Premium**: Added user admin area allowing management of all baserow users.
|
||||
|
||||
## Released (2021-04-08)
|
||||
|
|
|
@ -63,3 +63,4 @@
|
|||
@import 'admin_settings';
|
||||
@import 'templates';
|
||||
@import 'paginator';
|
||||
@import 'sortable';
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
}
|
||||
|
||||
.select__items {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
.sortable-sorting-item {
|
||||
user-select: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.sortable-position-indicator {
|
||||
position: absolute;
|
||||
height: 3px;
|
||||
background-color: $color-neutral-500;
|
||||
border-radius: 6px;
|
||||
}
|
|
@ -19,6 +19,7 @@
|
|||
<GroupsContextItem
|
||||
v-for="group in searchAndSort(groups)"
|
||||
:key="group.id"
|
||||
v-sortable="{ id: group.id, update: order }"
|
||||
:group="group"
|
||||
@selected="hide"
|
||||
></GroupsContextItem>
|
||||
|
@ -45,6 +46,7 @@ import { mapGetters, mapState } from 'vuex'
|
|||
import CreateGroupModal from '@baserow/modules/core/components/group/CreateGroupModal'
|
||||
import GroupsContextItem from '@baserow/modules/core/components/group/GroupsContextItem'
|
||||
import context from '@baserow/modules/core/mixins/context'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export default {
|
||||
name: 'GroupsContext',
|
||||
|
@ -79,13 +81,24 @@ export default {
|
|||
searchAndSort(groups) {
|
||||
const query = this.query
|
||||
|
||||
return groups.filter(function (group) {
|
||||
const regex = new RegExp('(' + query + ')', 'i')
|
||||
return group.name.match(regex)
|
||||
})
|
||||
// .sort((a, b) => {
|
||||
// return a.order - b.order
|
||||
// })
|
||||
return groups
|
||||
.filter(function (group) {
|
||||
const regex = new RegExp('(' + query + ')', 'i')
|
||||
return group.name.match(regex)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.order - b.order
|
||||
})
|
||||
},
|
||||
async order(order, oldOrder) {
|
||||
try {
|
||||
await this.$store.dispatch('group/order', {
|
||||
order,
|
||||
oldOrder,
|
||||
})
|
||||
} catch (error) {
|
||||
notifyIf(error, 'group')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
ref="contextLink"
|
||||
class="select__item-options"
|
||||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
|
||||
@mousedown.stop
|
||||
>
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</a>
|
||||
|
|
240
web-frontend/modules/core/directives/sortable.js
Normal file
240
web-frontend/modules/core/directives/sortable.js
Normal file
|
@ -0,0 +1,240 @@
|
|||
/**
|
||||
* This directive can by used to enable vertical drag and drop sorting of an array of
|
||||
* items. When the dragging starts, it simply shows a target position and will call a
|
||||
* function when the item has been dropped. It will not actually change the order of
|
||||
* the items, but it will only show the drag and drop effect and calculates the new
|
||||
* order of the items. The actual updating has to happen in the update function.
|
||||
*
|
||||
* Optionally a handle selector can be provided by doing
|
||||
* `v-sortable="{ id: item.id, update: order, handle: '.child-element' }"`.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```
|
||||
* <div
|
||||
* v-for="item in items"
|
||||
* :key="item.id"
|
||||
* v-sortable="{ id: item.id, update: order }"
|
||||
* ></div>
|
||||
*
|
||||
* export default {
|
||||
* data() {
|
||||
* return {
|
||||
* items: [{'id': 1, order: 1}, {'id': 2, order: 2}, {'id': 3, order: 3}]
|
||||
* }
|
||||
* },
|
||||
* methods: {
|
||||
* order(order) {
|
||||
* console.log(order) // [1, 2, 3]
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
let parent
|
||||
let indicator
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Called when the directive must bind to the element. It will register the
|
||||
* mousedown event on the element, which is used to start the drag and drop
|
||||
* process.
|
||||
*/
|
||||
bind(el, binding) {
|
||||
el.sortableId = binding.value.id
|
||||
el.sortableAutoScrolling = false
|
||||
|
||||
const mousedownElement = binding.value.handle
|
||||
? el.querySelector(binding.value.handle)
|
||||
: el
|
||||
|
||||
el.mousedownEvent = () => {
|
||||
el.sortableMoved = false
|
||||
|
||||
el.mouseMoveEvent = (event) => binding.def.move(el, binding, event)
|
||||
window.addEventListener('mousemove', el.mouseMoveEvent)
|
||||
|
||||
el.mouseUpEvent = (event) => binding.def.up(el, binding, event)
|
||||
window.addEventListener('mouseup', el.mouseUpEvent)
|
||||
|
||||
el.keydownEvent = (event) => {
|
||||
if (event.keyCode === 27) {
|
||||
// When the user presses the escape key we want to cancel the action
|
||||
binding.def.cancel(el, event)
|
||||
}
|
||||
}
|
||||
document.body.addEventListener('keydown', el.keydownEvent)
|
||||
|
||||
parent = el.parentNode
|
||||
indicator = document.createElement('div')
|
||||
indicator.classList.add('sortable-position-indicator')
|
||||
parent.insertBefore(indicator, parent.firstChild)
|
||||
}
|
||||
mousedownElement.addEventListener('mousedown', el.mousedownEvent)
|
||||
},
|
||||
/**
|
||||
* When the directive must unbind from the element, we will remove all the events
|
||||
* that could have been added.
|
||||
*/
|
||||
unbind(el, binding) {
|
||||
if (el.sortableMoved) {
|
||||
binding.def.cancel(el)
|
||||
}
|
||||
|
||||
const mousedownElement = binding.value.handle
|
||||
? el.querySelector(binding.value.handle)
|
||||
: el
|
||||
mousedownElement.removeEventListener('mousedown', el.mousedownEvent)
|
||||
},
|
||||
update(el, binding) {
|
||||
el.sortableId = binding.value.id
|
||||
},
|
||||
/**
|
||||
* Called when the user moves the mouse when the dragging of the element has
|
||||
* started. It will calculate the target indicator position and saves before which
|
||||
* element it must be placed.
|
||||
*/
|
||||
move(el, binding, event = null, startAutoScroll = true) {
|
||||
if (event !== null) {
|
||||
event.preventDefault()
|
||||
el.sortableLastMoveEvent = event
|
||||
} else {
|
||||
event = el.sortableLastMoveEvent
|
||||
}
|
||||
el.sortableMoved = true
|
||||
|
||||
// Set pointer events to none because that will prevent hover and click
|
||||
// effects.
|
||||
const all = [...parent.childNodes].filter((e) => e !== indicator)
|
||||
|
||||
// Add the `sortable-sorting-item` which disables the pointer events and user
|
||||
// select of all the sortable items. This will give a smoother user experience
|
||||
// as the user can't accidentally click the item and can't select the text while
|
||||
// dragging.
|
||||
all.forEach((s) => {
|
||||
s.classList.add('sortable-sorting-item')
|
||||
})
|
||||
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
|
||||
// Using the mouse position and the position of the items we can calculate
|
||||
// before which item the dragging item must be placed. If the position of the
|
||||
// mouse is above the vertical center of the element, it must be placed before
|
||||
// that item.
|
||||
let before = null
|
||||
let beforeRect = {}
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
beforeRect = all[i].getBoundingClientRect()
|
||||
if (event.clientY < beforeRect.top + beforeRect.height / 2) {
|
||||
before = all[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Save the element where the dragging item must be placed before so that the
|
||||
// new order can be calculated when the user releases the mouse.
|
||||
el.sortableBeforeElement = before
|
||||
|
||||
// Calculate the target indicator position based on the position of the
|
||||
// beforeElement. If the beforeElement is null, it means that the dragging
|
||||
// element must be moved to the end.
|
||||
const elementRect = el.getBoundingClientRect()
|
||||
const afterRect = all[all.length - 1].getBoundingClientRect()
|
||||
const top =
|
||||
(before
|
||||
? beforeRect.top - indicator.clientHeight
|
||||
: afterRect.top + afterRect.height) -
|
||||
parentRect.top +
|
||||
parent.scrollTop
|
||||
const left = elementRect.left - parentRect.left
|
||||
indicator.style.left = left + 'px'
|
||||
indicator.style.width = elementRect.width + 'px'
|
||||
indicator.style.top = top + 'px'
|
||||
|
||||
// If the user is not already auto scrolling, which happens while dragging and
|
||||
// moving the element close to the end of the view port at the top or bottom
|
||||
// side, we might need to initiate that process.
|
||||
if (
|
||||
parent.scrollHeight > parent.clientHeight &&
|
||||
(!el.sortableAutoScrolling || !startAutoScroll)
|
||||
) {
|
||||
const parentHeight = parentRect.bottom - parentRect.top
|
||||
const side = Math.ceil((parentHeight / 100) * 10)
|
||||
const autoScrollMouseTop = event.clientY - parentRect.top
|
||||
const autoScrollMouseBottom = parentHeight - autoScrollMouseTop
|
||||
let speed = 0
|
||||
|
||||
if (autoScrollMouseTop < side) {
|
||||
speed = -(3 - Math.ceil((Math.max(0, autoScrollMouseTop) / side) * 3))
|
||||
} else if (autoScrollMouseBottom < side) {
|
||||
speed = 3 - Math.ceil((Math.max(0, autoScrollMouseBottom) / side) * 3)
|
||||
}
|
||||
|
||||
// If the speed is either a position or negative, so not 0, we know that we
|
||||
// need to start auto scrolling.
|
||||
if (speed !== 0) {
|
||||
el.sortableAutoScrolling = true
|
||||
parent.scrollTop += speed
|
||||
el.sortableScrollTimeout = setTimeout(() => {
|
||||
binding.def.move(el, binding, null, false)
|
||||
}, 10)
|
||||
} else {
|
||||
el.sortableAutoScrolling = false
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when the user releases the mouse after the dragging of the element has
|
||||
* started. It will check calculate the new order of all items based on the last
|
||||
* beforeElement element saved by the move method. If the item has changed
|
||||
* position, the update function is called which needs to change the actual order
|
||||
* of the items.
|
||||
*/
|
||||
up(el, binding) {
|
||||
binding.def.cancel(el, binding)
|
||||
|
||||
if (!el.sortableMoved) {
|
||||
return
|
||||
}
|
||||
|
||||
el.sortableMoved = false
|
||||
|
||||
const oldOrder = [...parent.childNodes].map((e) => e.sortableId)
|
||||
const newOrder = oldOrder.filter((id) => id !== el.sortableId)
|
||||
const targetIndex = el.sortableBeforeElement
|
||||
? newOrder.findIndex((id) => id === el.sortableBeforeElement.sortableId)
|
||||
: newOrder.length
|
||||
|
||||
if (targetIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
newOrder.splice(targetIndex, 0, el.sortableId)
|
||||
|
||||
if (JSON.stringify(oldOrder) === JSON.stringify(newOrder)) {
|
||||
return
|
||||
}
|
||||
|
||||
binding.value.update(newOrder, oldOrder)
|
||||
},
|
||||
/**
|
||||
* Cancels the sorting by removing the target indicator, sorting classes and event
|
||||
* listeners.
|
||||
*/
|
||||
cancel(el) {
|
||||
clearTimeout(el.sortableScrollTimeout)
|
||||
|
||||
if (indicator.parentNode) {
|
||||
indicator.parentNode.removeChild(indicator)
|
||||
}
|
||||
|
||||
const all = parent.childNodes
|
||||
all.forEach((s) => {
|
||||
s.classList.remove('sortable-sorting-item')
|
||||
})
|
||||
|
||||
window.removeEventListener('mouseup', el.mouseUpEvent)
|
||||
window.removeEventListener('mousemove', el.mouseMoveEvent)
|
||||
document.body.removeEventListener('keydown', el.keydownEvent)
|
||||
},
|
||||
}
|
|
@ -19,6 +19,7 @@ import nameAbbreviation from '@baserow/modules/core/filters/nameAbbreviation'
|
|||
import scroll from '@baserow/modules/core/directives/scroll'
|
||||
import preventParentScroll from '@baserow/modules/core/directives/preventParentScroll'
|
||||
import tooltip from '@baserow/modules/core/directives/tooltip'
|
||||
import sortable from '@baserow/modules/core/directives/sortable'
|
||||
|
||||
Vue.component('Context', Context)
|
||||
Vue.component('Modal', Modal)
|
||||
|
@ -39,3 +40,4 @@ Vue.filter('nameAbbreviation', nameAbbreviation)
|
|||
Vue.directive('scroll', scroll)
|
||||
Vue.directive('preventParentScroll', preventParentScroll)
|
||||
Vue.directive('tooltip', tooltip)
|
||||
Vue.directive('sortable', sortable)
|
||||
|
|
|
@ -3,6 +3,11 @@ export default (client) => {
|
|||
fetchAll() {
|
||||
return client.get('/groups/')
|
||||
},
|
||||
order(order) {
|
||||
return client.post('/groups/order/', {
|
||||
groups: order,
|
||||
})
|
||||
},
|
||||
create(values) {
|
||||
return client.post('/groups/', values)
|
||||
},
|
||||
|
|
|
@ -45,6 +45,12 @@ export const mutations = {
|
|||
const index = state.items.findIndex((item) => item.id === id)
|
||||
Object.assign(state.items[index], state.items[index], values)
|
||||
},
|
||||
ORDER_ITEMS(state, order) {
|
||||
state.items.forEach((group) => {
|
||||
const index = order.findIndex((value) => value === group.id)
|
||||
group.order = index === -1 ? 0 : index + 1
|
||||
})
|
||||
},
|
||||
DELETE_ITEM(state, id) {
|
||||
const index = state.items.findIndex((item) => item.id === id)
|
||||
state.items.splice(index, 1)
|
||||
|
@ -137,6 +143,19 @@ export const actions = {
|
|||
forceUpdate({ commit }, { group, values }) {
|
||||
commit('UPDATE_ITEM', { id: group.id, values })
|
||||
},
|
||||
/**
|
||||
* Updates the order of the groups for the current user.
|
||||
*/
|
||||
async order({ commit, getters }, { order, oldOrder }) {
|
||||
commit('ORDER_ITEMS', order)
|
||||
|
||||
try {
|
||||
await GroupService(this.$client).order(order)
|
||||
} catch (error) {
|
||||
commit('ORDER_ITEMS', oldOrder)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deletes an existing group with the provided id.
|
||||
*/
|
||||
|
|
|
@ -14,8 +14,9 @@
|
|||
</div>
|
||||
<ul v-if="!isLoading && views.length > 0" class="select__items">
|
||||
<ViewsContextItem
|
||||
v-for="view in search(views)"
|
||||
v-for="view in searchAndOrder(views)"
|
||||
:key="view.id"
|
||||
v-sortable="{ id: view.id, update: order }"
|
||||
:view="view"
|
||||
:read-only="readOnly"
|
||||
@selected="selectedView"
|
||||
|
@ -54,6 +55,7 @@
|
|||
import { mapState } from 'vuex'
|
||||
|
||||
import context from '@baserow/modules/core/mixins/context'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import ViewsContextItem from '@baserow/modules/database/components/view/ViewsContextItem'
|
||||
import CreateViewModal from '@baserow/modules/database/components/view/CreateViewModal'
|
||||
|
||||
|
@ -102,13 +104,26 @@ export default {
|
|||
const target = this.$refs['createViewModalToggle' + type][0]
|
||||
this.$refs['createViewModal' + type][0].toggle(target)
|
||||
},
|
||||
search(views) {
|
||||
searchAndOrder(views) {
|
||||
const query = this.query
|
||||
|
||||
return views.filter(function (view) {
|
||||
const regex = new RegExp('(' + query + ')', 'i')
|
||||
return view.name.match(regex)
|
||||
})
|
||||
return views
|
||||
.filter(function (view) {
|
||||
const regex = new RegExp('(' + query + ')', 'i')
|
||||
return view.name.match(regex)
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
},
|
||||
async order(order, oldOrder) {
|
||||
try {
|
||||
await this.$store.dispatch('view/order', {
|
||||
table: this.table,
|
||||
order,
|
||||
oldOrder,
|
||||
})
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
ref="contextLink"
|
||||
class="select__item-options"
|
||||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
|
||||
@mousedown.stop
|
||||
>
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</a>
|
||||
|
|
|
@ -149,6 +149,13 @@ export const registerRealtimeEvents = (realtime) => {
|
|||
}
|
||||
})
|
||||
|
||||
realtime.registerEvent('views_reordered', ({ store, app }, data) => {
|
||||
const table = store.getters['table/getSelected']
|
||||
if (table !== undefined && table.id === data.table_id) {
|
||||
store.commit('view/ORDER_ITEMS', data.order)
|
||||
}
|
||||
})
|
||||
|
||||
realtime.registerEvent('view_deleted', ({ store }, data) => {
|
||||
const view = store.getters['view/get'](data.view_id)
|
||||
if (view !== undefined) {
|
||||
|
|
|
@ -29,6 +29,11 @@ export default (client) => {
|
|||
update(viewId, values) {
|
||||
return client.patch(`/database/views/${viewId}/`, values)
|
||||
},
|
||||
order(tableId, order) {
|
||||
return client.post(`/database/views/table/${tableId}/order/`, {
|
||||
view_ids: order,
|
||||
})
|
||||
},
|
||||
delete(viewId) {
|
||||
return client.delete(`/database/views/${viewId}/`)
|
||||
},
|
||||
|
|
|
@ -77,6 +77,12 @@ export const mutations = {
|
|||
const index = state.items.findIndex((item) => item.id === id)
|
||||
Object.assign(state.items[index], state.items[index], values)
|
||||
},
|
||||
ORDER_ITEMS(state, order) {
|
||||
state.items.forEach((view) => {
|
||||
const index = order.findIndex((value) => value === view.id)
|
||||
view.order = index === -1 ? 0 : index + 1
|
||||
})
|
||||
},
|
||||
DELETE_ITEM(state, id) {
|
||||
const index = state.items.findIndex((item) => item.id === id)
|
||||
state.items.splice(index, 1)
|
||||
|
@ -241,6 +247,19 @@ export const actions = {
|
|||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates the order of all the views in a table.
|
||||
*/
|
||||
async order({ commit, getters }, { table, order, oldOrder }) {
|
||||
commit('ORDER_ITEMS', order)
|
||||
|
||||
try {
|
||||
await ViewService(this.$client).order(table.id, order)
|
||||
} catch (error) {
|
||||
commit('ORDER_ITEMS', oldOrder)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Forcefully update an existing view without making a request to the backend.
|
||||
*/
|
||||
|
|
Loading…
Add table
Reference in a new issue