1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Merge branch '337-make-the-views-orderable' into 'develop'

Resolve "Make the views orderable"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-05-18 09:56:49 +00:00
commit fbf8cf4e88
29 changed files with 634 additions and 14 deletions
backend
changelog.md
web-frontend/modules

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -63,3 +63,4 @@
@import 'admin_settings';
@import 'templates';
@import 'paginator';
@import 'sortable';

View file

@ -28,6 +28,7 @@
}
.select__items {
position: relative;
list-style: none;
padding: 0;
margin: 0;

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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