1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-11 07:51:20 +00:00

Merge branch '335-make-the-applications-orderable' into 'develop'

Resolve "Make the applications orderable"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-05-18 13:43:07 +00:00
commit 90ed295d13
21 changed files with 397 additions and 40 deletions

View file

@ -1,4 +1,4 @@
from rest_framework.status import HTTP_404_NOT_FOUND
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_APPLICATION_DOES_NOT_EXIST = (
@ -6,3 +6,8 @@ ERROR_APPLICATION_DOES_NOT_EXIST = (
HTTP_404_NOT_FOUND,
"The requested application does not exist.",
)
ERROR_APPLICATION_NOT_IN_GROUP = (
"ERROR_APPLICATION_NOT_IN_GROUP",
HTTP_400_BAD_REQUEST,
"The application id {e.application_id} does not belong to the group.",
)

View file

@ -48,6 +48,13 @@ class ApplicationUpdateSerializer(serializers.ModelSerializer):
fields = ("name",)
class OrderApplicationsSerializer(serializers.Serializer):
application_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="Application ids in the desired order.",
)
def get_application_serializer(instance, **kwargs):
"""
Returns an instantiated serializer based on the instance class type. Custom

View file

@ -1,6 +1,11 @@
from django.conf.urls import url
from .views import ApplicationsView, AllApplicationsView, ApplicationView
from .views import (
ApplicationsView,
AllApplicationsView,
ApplicationView,
OrderApplicationsView,
)
app_name = "baserow.api.group"
@ -8,6 +13,11 @@ app_name = "baserow.api.group"
urlpatterns = [
url(r"group/(?P<group_id>[0-9]+)/$", ApplicationsView.as_view(), name="list"),
url(
r"group/(?P<group_id>[0-9]+)/order/$",
OrderApplicationsView.as_view(),
name="order",
),
url(r"(?P<application_id>[0-9]+)/$", ApplicationView.as_view(), name="item"),
url(r"$", AllApplicationsView.as_view(), name="list"),
]

View file

@ -11,13 +11,17 @@ from baserow.api.decorators import validate_body, map_exceptions
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP, ERROR_GROUP_DOES_NOT_EXIST
from baserow.api.schemas import get_error_schema
from baserow.api.utils import PolymorphicMappingSerializer
from baserow.api.applications.errors import ERROR_APPLICATION_DOES_NOT_EXIST
from baserow.api.applications.errors import (
ERROR_APPLICATION_DOES_NOT_EXIST,
ERROR_APPLICATION_NOT_IN_GROUP,
)
from baserow.core.models import Application
from baserow.core.handler import CoreHandler
from baserow.core.exceptions import (
UserNotInGroup,
GroupDoesNotExist,
ApplicationDoesNotExist,
ApplicationNotInGroup,
)
from baserow.core.registries import application_type_registry
@ -25,6 +29,7 @@ from .serializers import (
ApplicationSerializer,
ApplicationCreateSerializer,
ApplicationUpdateSerializer,
OrderApplicationsSerializer,
get_application_serializer,
)
@ -312,3 +317,50 @@ class ApplicationView(APIView):
CoreHandler().delete_application(request.user, application)
return Response(status=204)
class OrderApplicationsView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="group_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="Updates the order of the applications in the group "
"related to the provided value.",
),
],
tags=["Applications"],
operation_id="order_applications",
description=(
"Changes the order of the provided application 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 "
"tables will be set to `0`."
),
request=OrderApplicationsSerializer,
responses={
204: None,
400: get_error_schema(
["ERROR_USER_NOT_IN_GROUP", "ERROR_APPLICATION_NOT_IN_GROUP"]
),
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
},
)
@validate_body(OrderApplicationsSerializer)
@transaction.atomic
@map_exceptions(
{
GroupDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
ApplicationNotInGroup: ERROR_APPLICATION_NOT_IN_GROUP,
}
)
def post(self, request, data, group_id):
"""Updates to order of the applications in a table."""
group = CoreHandler().get_group(group_id)
CoreHandler().order_applications(request.user, group, data["application_ids"])
return Response(status=204)

View file

@ -51,6 +51,18 @@ class ApplicationDoesNotExist(Exception):
"""Raised when trying to get an application that does not exist."""
class ApplicationNotInGroup(Exception):
"""Raised when a provided application does not belong to a group."""
def __init__(self, application_id=None, *args, **kwargs):
self.application_id = application_id
super().__init__(
f"The application {application_id} does not belong to the group.",
*args,
**kwargs,
)
class InstanceTypeAlreadyRegistered(Exception):
"""
Raised when the instance model instance is already registered in the registry.

View file

@ -28,6 +28,7 @@ from .models import (
from .exceptions import (
GroupDoesNotExist,
ApplicationDoesNotExist,
ApplicationNotInGroup,
BaseURLHostnameNotAllowed,
GroupInvitationEmailMismatch,
GroupInvitationDoesNotExist,
@ -43,6 +44,7 @@ from .signals import (
application_created,
application_updated,
application_deleted,
applications_reordered,
group_created,
group_updated,
group_deleted,
@ -656,6 +658,33 @@ class CoreHandler:
return application
def order_applications(self, user, group, order):
"""
Updates the order of the applications in the given group. The order of the
applications that are not in the `order` parameter set set to `0`.
:param user: The user on whose behalf the tables are ordered.
:type user: User
:param group: The group of which the applications must be updated.
:type group: Group
:param order: A list containing the application ids in the desired order.
:type order: list
:raises ApplicationNotInGroup: If one of the applications ids in the order does
not belong to the database.
"""
group.has_user(user, raise_error=True)
queryset = Application.objects.filter(group_id=group.id)
application_ids = queryset.values_list("id", flat=True)
for application_id in order:
if application_id not in application_ids:
raise ApplicationNotInGroup(application_id)
Application.order_objects(queryset, order)
applications_reordered.send(self, group=group, order=order, user=user)
def delete_application(self, user, application):
"""
Deletes an existing application instance if the user has access to the

View file

@ -11,3 +11,4 @@ group_user_deleted = Signal()
application_created = Signal()
application_updated = Signal()
application_deleted = Signal()
applications_reordered = Signal()

View file

@ -109,3 +109,18 @@ def application_deleted(sender, application_id, application, user, **kwargs):
getattr(user, "web_socket_id", None),
)
)
@receiver(signals.applications_reordered)
def applications_reordered(sender, group, order, user, **kwargs):
transaction.on_commit(
lambda: broadcast_to_group.delay(
group.id,
{
"type": "applications_reordered",
"group_id": group.id,
"order": order,
},
getattr(user, "web_socket_id", None),
)
)

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,
@ -235,3 +236,66 @@ def test_delete_application(api_client, data_fixture):
assert response.status_code == 204
assert Database.objects.all().count() == 1
@pytest.mark.django_db
def test_order_tables(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
group_1 = data_fixture.create_group(user=user)
group_2 = data_fixture.create_group()
application_1 = data_fixture.create_database_application(group=group_1, order=1)
application_2 = data_fixture.create_database_application(group=group_1, order=2)
application_3 = data_fixture.create_database_application(group=group_1, order=3)
response = api_client.post(
reverse("api:applications:order", kwargs={"group_id": group_2.id}),
{"application_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:applications:order", kwargs={"group_id": 999999}),
{"application_ids": []},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_GROUP_DOES_NOT_EXIST"
response = api_client.post(
reverse("api:applications:order", kwargs={"group_id": group_1.id}),
{"application_ids": [0]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_APPLICATION_NOT_IN_GROUP"
response = api_client.post(
reverse("api:applications:order", kwargs={"group_id": group_1.id}),
{"application_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:applications:order", kwargs={"group_id": group_1.id}),
{"application_ids": [application_3.id, application_2.id, application_1.id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
application_1.refresh_from_db()
application_2.refresh_from_db()
application_3.refresh_from_db()
assert application_1.order == 3
assert application_2.order == 2
assert application_3.order == 1

View file

@ -14,6 +14,7 @@ from baserow.contrib.database.models import Database, Table
from baserow.core.exceptions import (
UserNotInGroup,
ApplicationTypeDoesNotExist,
ApplicationNotInGroup,
GroupDoesNotExist,
GroupUserDoesNotExist,
ApplicationDoesNotExist,
@ -694,6 +695,66 @@ def test_update_database_application(send_mock, data_fixture):
assert database.name == "Test 1"
@pytest.mark.django_db
@patch("baserow.core.signals.applications_reordered.send")
def test_order_applications(send_mock, data_fixture):
user = data_fixture.create_user()
user_2 = data_fixture.create_user()
group = data_fixture.create_group(user=user)
application_1 = data_fixture.create_database_application(group=group, order=1)
application_2 = data_fixture.create_database_application(group=group, order=2)
application_3 = data_fixture.create_database_application(group=group, order=3)
handler = CoreHandler()
with pytest.raises(UserNotInGroup):
handler.order_applications(user=user_2, group=group, order=[])
with pytest.raises(ApplicationNotInGroup):
handler.order_applications(user=user, group=group, order=[0])
handler.order_applications(
user=user,
group=group,
order=[application_3.id, application_2.id, application_1.id],
)
application_1.refresh_from_db()
application_2.refresh_from_db()
application_3.refresh_from_db()
assert application_1.order == 3
assert application_2.order == 2
assert application_3.order == 1
send_mock.assert_called_once()
assert send_mock.call_args[1]["group"].id == group.id
assert send_mock.call_args[1]["user"].id == user.id
assert send_mock.call_args[1]["order"] == [
application_3.id,
application_2.id,
application_1.id,
]
handler.order_applications(
user=user,
group=group,
order=[application_1.id, application_3.id, application_2.id],
)
application_1.refresh_from_db()
application_2.refresh_from_db()
application_3.refresh_from_db()
assert application_1.order == 1
assert application_2.order == 3
assert application_3.order == 2
handler.order_applications(user=user, group=group, order=[application_1.id])
application_1.refresh_from_db()
application_2.refresh_from_db()
application_3.refresh_from_db()
assert application_1.order == 1
assert application_2.order == 0
assert application_3.order == 0
@pytest.mark.django_db
@patch("baserow.core.signals.application_deleted.send")
def test_delete_database_application(send_mock, data_fixture):

View file

@ -133,3 +133,19 @@ def test_application_deleted(mock_broadcast_to_group, data_fixture):
assert args[0][0] == database.group_id
assert args[0][1]["type"] == "application_deleted"
assert args[0][1]["application_id"] == database_id
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
def test_applications_reordered(mock_broadcast_to_channel_group, data_fixture):
user = data_fixture.create_user()
group = data_fixture.create_group(user=user)
database = data_fixture.create_database_application(group=group)
CoreHandler().order_applications(user=user, group=group, order=[database.id])
mock_broadcast_to_channel_group.delay.assert_called_once()
args = mock_broadcast_to_channel_group.delay.call_args
assert args[0][0] == database.group_id
assert args[0][1]["type"] == "applications_reordered"
assert args[0][1]["group_id"] == group.id
assert args[0][1]["order"] == [database.id]

View file

@ -7,6 +7,7 @@
* Made it possible to import a JSON file when creating a table.
* Made it possible to order the views by drag and drop.
* Made it possible to order the groups by drag and drop.
* Made it possible to order the applications by drag and drop.
* Made it possible to order the tables by drag and drop.
## Released (2021-05-11)

View file

@ -1,4 +1,5 @@
.tree {
position: relative;
list-style: none;
padding: 0;
margin: 0 0 12px;
@ -57,7 +58,6 @@
@extend %tree__size;
@extend %ellipsis;
display: block;
color: $color-primary-900;
font-size: 14px;

View file

@ -62,8 +62,10 @@
>
<div class="tree__action sidebar__action">
<nuxt-link :to="{ name: 'dashboard' }" class="tree__link">
<i class="tree__icon fas fa-tachometer-alt"></i>
<span class="sidebar__item-name">Dashboard</span>
<div>
<i class="tree__icon fas fa-tachometer-alt"></i>
<span class="sidebar__item-name">Dashboard</span>
</div>
</nuxt-link>
</div>
</li>
@ -73,8 +75,10 @@
:class="{ 'tree__action--disabled': isAdminPage }"
>
<a class="tree__link" @click.prevent="admin()">
<i class="tree__icon fas fa-users-cog"></i>
<span class="sidebar__item-name">Admin</span>
<div>
<i class="tree__icon fas fa-users-cog"></i>
<span class="sidebar__item-name">Admin</span>
</div>
</a>
</div>
<ul v-show="isAdminPage" class="tree sidebar__tree">
@ -93,11 +97,15 @@
:to="{ name: adminType.routeName }"
class="tree__link"
>
<i
class="tree__icon fas"
:class="'fa-' + adminType.iconClass"
></i>
<span class="sidebar__item-name">{{ adminType.name }}</span>
<div>
<i
class="tree__icon fas"
:class="'fa-' + adminType.iconClass"
></i>
<span class="sidebar__item-name">{{
adminType.name
}}</span>
</div>
</nuxt-link>
</div>
</li>
@ -117,7 +125,7 @@
0
)
"
>{{ selectedGroup.name }}</a
><div>{{ selectedGroup.name }}</div></a
>
<GroupsContext ref="groupSelect"></GroupsContext>
</div>
@ -125,8 +133,10 @@
<li v-if="selectedGroup.permissions === 'ADMIN'" class="tree__item">
<div class="tree__action">
<a class="tree__link" @click="$refs.groupMembersModal.show()">
<i class="tree__icon tree__icon--type fas fa-users"></i>
Invite others
<div>
<i class="tree__icon tree__icon--type fas fa-users"></i>
Invite others
</div>
</a>
<GroupMembersModal
ref="groupMembersModal"
@ -139,6 +149,11 @@
:is="getApplicationComponent(application)"
v-for="application in applications"
:key="application.id"
v-sortable="{
id: application.id,
update: orderApplications,
handle: '[data-sortable-handle]',
}"
:application="application"
></component>
</ul>
@ -175,7 +190,7 @@
<a
class="tree__link tree__link--group"
@click="$store.dispatch('group/select', group)"
>{{ group.name }}</a
><div>{{ group.name }}</div></a
>
<i class="tree__right-icon fas fa-arrow-right"></i>
</div>
@ -218,6 +233,7 @@
<script>
import { mapGetters, mapState } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import SettingsModal from '@baserow/modules/core/components/settings/SettingsModal'
import SidebarApplication from '@baserow/modules/core/components/sidebar/SidebarApplication'
import CreateApplicationContext from '@baserow/modules/core/components/application/CreateApplicationContext'
@ -243,7 +259,7 @@ export default {
applications() {
return this.$store.getters['application/getAllOfGroup'](
this.selectedGroup
)
).sort((a, b) => a.order - b.order)
},
adminTypes() {
return this.$registry.getAll('admin')
@ -302,6 +318,17 @@ export default {
this.$nuxt.$router.push({ name: this.sortedAdminTypes[0].routeName })
}
},
async orderApplications(order, oldOrder) {
try {
await this.$store.dispatch('application/order', {
group: this.selectedGroup,
order,
oldOrder,
})
} catch (error) {
notifyIf(error, 'application')
}
},
},
}
</script>

View file

@ -6,22 +6,25 @@
'tree__item--loading': application._.loading,
}"
>
<div class="tree__action tree__action--has-options">
<div class="tree__action tree__action--has-options" data-sortable-handle>
<a class="tree__link" @click="$emit('selected', application)">
<i
class="tree__icon tree__icon--type fas"
:class="'fa-' + application._.type.iconClass"
></i>
<Editable
ref="rename"
:value="application.name"
@change="renameApplication(application, $event)"
></Editable>
<div>
<i
class="tree__icon tree__icon--type fas"
:class="'fa-' + application._.type.iconClass"
></i>
<Editable
ref="rename"
:value="application.name"
@change="renameApplication(application, $event)"
></Editable>
</div>
</a>
<a
ref="contextLink"
class="tree__options"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
@mousedown.stop
>
<i class="fas fa-ellipsis-v"></i>
</a>

View file

@ -9,7 +9,7 @@
</li>
<component
:is="getApplicationComponent(application)"
v-for="application in applications"
v-for="application in sortedApplications"
:key="application.id"
:application="application"
:page="page"
@ -60,6 +60,15 @@ export default {
required: true,
},
},
computed: {
sortedApplications() {
return this.applications
.map((a) => a)
.sort((a, b) => {
return a.order - b.order
})
},
},
methods: {
getApplicationComponent(application) {
return this.$registry

View file

@ -8,11 +8,13 @@
>
<div class="tree__action">
<a class="tree__link">
<i
class="tree__icon tree__icon--type fas"
:class="'fa-' + application._.type.iconClass"
></i>
{{ application.name }}
<div>
<i
class="tree__icon tree__icon--type fas"
:class="'fa-' + application._.type.iconClass"
></i>
{{ application.name }}
</div>
</a>
</div>
<template

View file

@ -230,6 +230,13 @@ export class RealTimeHandler {
store.dispatch('application/forceDelete', application)
}
})
this.registerEvent('applications_reordered', ({ store }, data) => {
const group = store.getters['group/get'](data.group_id)
if (group !== undefined) {
store.commit('application/ORDER_ITEMS', { group, order: data.order })
}
})
}
}

View file

@ -13,6 +13,11 @@ export default (client) => {
update(applicationId, values) {
return client.patch(`/applications/${applicationId}/`, values)
},
order(groupId, order) {
return client.post(`/applications/group/${groupId}/order/`, {
application_ids: order,
})
},
delete(applicationId) {
return client.delete(`/applications/${applicationId}/`)
},

View file

@ -40,6 +40,14 @@ export const mutations = {
const index = state.items.findIndex((item) => item.id === id)
Object.assign(state.items[index], state.items[index], values)
},
ORDER_ITEMS(state, { group, order }) {
state.items
.filter((item) => item.group.id === group.id)
.forEach((item) => {
const index = order.findIndex((value) => value === item.id)
item.order = index === -1 ? 0 : index + 1
})
},
DELETE_ITEM(state, id) {
const index = state.items.findIndex((item) => item.id === id)
state.items.splice(index, 1)
@ -171,6 +179,20 @@ export const actions = {
data = type.prepareForStoreUpdate(application, data)
commit('UPDATE_ITEM', { id: application.id, values: data })
},
/**
* Updates the order of all the applications in a group.
*/
async order({ commit, getters }, { group, order, oldOrder }) {
commit('ORDER_ITEMS', { group, order })
try {
await ApplicationService(this.$client).order(group.id, order)
} catch (error) {
commit('ORDER_ITEMS', { group, order: oldOrder })
throw error
}
},
/**
* Deletes an existing application.
*/

View file

@ -8,17 +8,19 @@
>
<div class="tree__action">
<a class="tree__link" @click="$emit('selected', application)">
<i
class="tree__icon tree__icon--type fas"
:class="'fa-' + application._.type.iconClass"
></i>
{{ application.name }}
<div>
<i
class="tree__icon tree__icon--type fas"
:class="'fa-' + application._.type.iconClass"
></i>
{{ application.name }}
</div>
</a>
</div>
<template v-if="application._.selected">
<ul class="tree__subs">
<li
v-for="table in application.tables"
v-for="table in orderedTables"
:key="table.id"
class="tree__sub"
:class="{ active: isTableActive(table) }"
@ -49,6 +51,13 @@ export default {
validator: (prop) => typeof prop === 'object' || prop === null,
},
},
computed: {
orderedTables() {
return this.application.tables
.map((table) => table)
.sort((a, b) => a.order - b.order)
},
},
methods: {
selectTable(application, table) {
this.$emit('selected-page', {