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

Merge branch '336-make-the-tables-orderable' into 'develop'

Resolve "Make the tables orderable"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-05-18 11:14:18 +00:00
commit 7e1fd1c184
20 changed files with 350 additions and 16 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
changelog.md
web-frontend/modules
core
assets/scss/components
directives
database

View file

@ -8,6 +8,11 @@ ERROR_TABLE_DOES_NOT_EXIST = (
HTTP_404_NOT_FOUND,
"The requested table does not exist.",
)
ERROR_TABLE_NOT_IN_DATABASE = (
"ERROR_TABLE_NOT_IN_DATABASE",
HTTP_400_BAD_REQUEST,
"The table id {e.table_id} does not belong to the database.",
)
ERROR_INVALID_INITIAL_TABLE_DATA = (
"ERROR_INVALID_INITIAL_TABLE_DATA",
HTTP_400_BAD_REQUEST,

View file

@ -52,3 +52,9 @@ class TableUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = Table
fields = ("name",)
class OrderTablesSerializer(serializers.Serializer):
table_ids = serializers.ListField(
child=serializers.IntegerField(), help_text="Table ids in the desired order."
)

View file

@ -1,11 +1,16 @@
from django.conf.urls import url
from .views import TablesView, TableView
from .views import TablesView, TableView, OrderTablesView
app_name = "baserow.contrib.database.api.tables"
urlpatterns = [
url(r"database/(?P<database_id>[0-9]+)/$", TablesView.as_view(), name="list"),
url(
r"database/(?P<database_id>[0-9]+)/order/$",
OrderTablesView.as_view(),
name="order",
),
url(r"(?P<table_id>[0-9]+)/$", TableView.as_view(), name="item"),
]

View file

@ -20,13 +20,20 @@ from baserow.contrib.database.table.models import Table
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.exceptions import (
TableDoesNotExist,
TableNotInDatabase,
InvalidInitialTableData,
InitialTableDataLimitExceeded,
)
from .serializers import TableSerializer, TableCreateSerializer, TableUpdateSerializer
from .serializers import (
TableSerializer,
TableCreateSerializer,
TableUpdateSerializer,
OrderTablesSerializer,
)
from .errors import (
ERROR_TABLE_DOES_NOT_EXIST,
ERROR_TABLE_NOT_IN_DATABASE,
ERROR_INVALID_INITIAL_TABLE_DATA,
ERROR_INITIAL_TABLE_DATA_LIMIT_EXCEEDED,
)
@ -248,3 +255,52 @@ class TableView(APIView):
TableHandler().delete_table(request.user, TableHandler().get_table(table_id))
return Response(status=204)
class OrderTablesView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="database_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="Updates the order of the tables in the database related "
"to the provided value.",
),
],
tags=["Database tables"],
operation_id="order_database_tables",
description=(
"Changes the order of the provided table 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=OrderTablesSerializer,
responses={
204: None,
400: get_error_schema(
["ERROR_USER_NOT_IN_GROUP", "ERROR_TABLE_NOT_IN_DATABASE"]
),
404: get_error_schema(["ERROR_APPLICATION_DOES_NOT_EXIST"]),
},
)
@validate_body(OrderTablesSerializer)
@transaction.atomic
@map_exceptions(
{
ApplicationDoesNotExist: ERROR_APPLICATION_DOES_NOT_EXIST,
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
TableNotInDatabase: ERROR_TABLE_NOT_IN_DATABASE,
}
)
def post(self, request, data, database_id):
"""Updates to order of the tables in a table."""
database = CoreHandler().get_application(
database_id, base_queryset=Database.objects
)
TableHandler().order_tables(request.user, database, data["table_ids"])
return Response(status=204)

View file

@ -2,6 +2,18 @@ class TableDoesNotExist(Exception):
"""Raised when trying to get a table that doesn't exist."""
class TableNotInDatabase(Exception):
"""Raised when a provided table does not belong to a database."""
def __init__(self, table_id=None, *args, **kwargs):
self.table_id = table_id
super().__init__(
f"The table {table_id} does not belong to the database.",
*args,
**kwargs,
)
class InvalidInitialTableData(Exception):
"""Raised when the provided initial table data does not contain a column or row."""

View file

@ -15,10 +15,11 @@ from baserow.contrib.database.fields.field_types import (
from .models import Table
from .exceptions import (
TableDoesNotExist,
TableNotInDatabase,
InvalidInitialTableData,
InitialTableDataLimitExceeded,
)
from .signals import table_created, table_updated, table_deleted
from .signals import table_created, table_updated, table_deleted, tables_reordered
class TableHandler:
@ -257,6 +258,34 @@ class TableHandler:
return table
def order_tables(self, user, database, order):
"""
Updates the order of the tables in the given database. The order of the views
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 database: The database of which the views must be updated.
:type database: Database
:param order: A list containing the table ids in the desired order.
:type order: list
:raises TableNotInDatabase: If one of the table ids in the order does not belong
to the database.
"""
group = database.group
group.has_user(user, raise_error=True)
queryset = Table.objects.filter(database_id=database.id)
table_ids = [table["id"] for table in queryset.values("id")]
for table_id in order:
if table_id not in table_ids:
raise TableNotInDatabase(table_id)
Table.order_objects(queryset, order)
tables_reordered.send(self, database=database, order=order, user=user)
def delete_table(self, user, table):
"""
Deletes an existing table instance if the user has access to the related group.

View file

@ -4,3 +4,4 @@ from django.dispatch import Signal
table_created = Signal()
table_updated = Signal()
table_deleted = Signal()
tables_reordered = Signal()

View file

@ -46,3 +46,18 @@ def table_deleted(sender, table_id, table, user, **kwargs):
getattr(user, "web_socket_id", None),
)
)
@receiver(table_signals.tables_reordered)
def tables_reordered(sender, database, order, user, **kwargs):
transaction.on_commit(
lambda: broadcast_to_group.delay(
database.group_id,
{
"type": "tables_reordered",
"database_id": database.id,
"order": order,
},
getattr(user, "web_socket_id", None),
)
)

View file

@ -1,6 +1,11 @@
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
from rest_framework.status import (
HTTP_200_OK,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_404_NOT_FOUND,
)
from django.shortcuts import reverse
from django.conf import settings
@ -345,6 +350,69 @@ def test_update_table(api_client, data_fixture):
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
@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"
)
database_1 = data_fixture.create_database_application(user=user)
database_2 = data_fixture.create_database_application()
table_1 = data_fixture.create_database_table(database=database_1, order=1)
table_2 = data_fixture.create_database_table(database=database_1, order=2)
table_3 = data_fixture.create_database_table(database=database_1, order=3)
response = api_client.post(
reverse("api:database:tables:order", kwargs={"database_id": database_2.id}),
{"table_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:tables:order", kwargs={"database_id": 999999}),
{"table_ids": []},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_APPLICATION_DOES_NOT_EXIST"
response = api_client.post(
reverse("api:database:tables:order", kwargs={"database_id": database_1.id}),
{"table_ids": [0]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_TABLE_NOT_IN_DATABASE"
response = api_client.post(
reverse("api:database:tables:order", kwargs={"database_id": database_1.id}),
{"table_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:tables:order", kwargs={"database_id": database_1.id}),
{"table_ids": [table_3.id, table_2.id, table_1.id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
table_1.refresh_from_db()
table_2.refresh_from_db()
table_3.refresh_from_db()
assert table_1.order == 3
assert table_2.order == 2
assert table_3.order == 1
@pytest.mark.django_db
def test_delete_table(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()

View file

@ -11,6 +11,7 @@ from baserow.contrib.database.table.models import Table
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.exceptions import (
TableDoesNotExist,
TableNotInDatabase,
InvalidInitialTableData,
InitialTableDataLimitExceeded,
)
@ -258,6 +259,58 @@ def test_update_database_table(send_mock, data_fixture):
assert table.name == "Test 1"
@pytest.mark.django_db
@patch("baserow.contrib.database.table.signals.tables_reordered.send")
def test_order_tables(send_mock, data_fixture):
user = data_fixture.create_user()
user_2 = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table_1 = data_fixture.create_database_table(database=database, order=1)
table_2 = data_fixture.create_database_table(database=database, order=2)
table_3 = data_fixture.create_database_table(database=database, order=3)
handler = TableHandler()
with pytest.raises(UserNotInGroup):
handler.order_tables(user=user_2, database=database, order=[])
with pytest.raises(TableNotInDatabase):
handler.order_tables(user=user, database=database, order=[0])
handler.order_tables(
user=user, database=database, order=[table_3.id, table_2.id, table_1.id]
)
table_1.refresh_from_db()
table_2.refresh_from_db()
table_3.refresh_from_db()
assert table_1.order == 3
assert table_2.order == 2
assert table_3.order == 1
send_mock.assert_called_once()
assert send_mock.call_args[1]["database"].id == database.id
assert send_mock.call_args[1]["user"].id == user.id
assert send_mock.call_args[1]["order"] == [table_3.id, table_2.id, table_1.id]
handler.order_tables(
user=user, database=database, order=[table_1.id, table_3.id, table_2.id]
)
table_1.refresh_from_db()
table_2.refresh_from_db()
table_3.refresh_from_db()
assert table_1.order == 1
assert table_2.order == 3
assert table_3.order == 2
handler.order_tables(user=user, database=database, order=[table_1.id])
table_1.refresh_from_db()
table_2.refresh_from_db()
table_3.refresh_from_db()
assert table_1.order == 1
assert table_2.order == 0
assert table_3.order == 0
@pytest.mark.django_db
@patch("baserow.contrib.database.table.signals.table_deleted.send")
def test_delete_database_table(send_mock, data_fixture):

View file

@ -34,6 +34,22 @@ def test_table_updated(mock_broadcast_to_group, data_fixture):
assert args[0][1]["table"]["id"] == table.id
@pytest.mark.django_db(transaction=True)
@patch("baserow.contrib.database.ws.table.signals.broadcast_to_group")
def test_tables_reordered(mock_broadcast_to_channel_group, data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(database=database)
TableHandler().order_tables(user=user, database=database, order=[table.id])
mock_broadcast_to_channel_group.delay.assert_called_once()
args = mock_broadcast_to_channel_group.delay.call_args
assert args[0][0] == table.database.group_id
assert args[0][1]["type"] == "tables_reordered"
assert args[0][1]["database_id"] == database.id
assert args[0][1]["order"] == [table.id]
@pytest.mark.django_db(transaction=True)
@patch("baserow.contrib.database.ws.table.signals.broadcast_to_group")
def test_table_deleted(mock_broadcast_to_users, data_fixture):

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 tables by drag and drop.
## Released (2021-05-11)

View file

@ -94,6 +94,7 @@
}
.tree__subs {
position: relative;
list-style: none;
padding: 0 0 2px 0;
margin: 0;
@ -131,12 +132,12 @@
.tree__sub-link {
@extend %tree_sub-size;
@extend %ellipsis;
color: $color-primary-900;
display: block;
&:hover {
cursor: pointer;
text-decoration: none;
color: $color-primary-500;
}

View file

@ -41,7 +41,7 @@ export default {
* process.
*/
bind(el, binding) {
el.sortableId = binding.value.id
binding.def.update(el, binding)
el.sortableAutoScrolling = false
const mousedownElement = binding.value.handle
@ -88,6 +88,8 @@ export default {
},
update(el, binding) {
el.sortableId = binding.value.id
el.sortableMarginLeft = binding.value.marginLeft
el.sortableMarginRight = binding.value.marginRight
},
/**
* Called when the user moves the mouse when the dragging of the element has
@ -147,8 +149,12 @@ export default {
parentRect.top +
parent.scrollTop
const left = elementRect.left - parentRect.left
indicator.style.left = left + 'px'
indicator.style.width = elementRect.width + 'px'
indicator.style.left = left + (el.sortableMarginLeft || 0) + 'px'
indicator.style.width =
elementRect.width -
(el.sortableMarginLeft || 0) -
(el.sortableMarginRight || 0) +
'px'
indicator.style.top = top + 'px'
// If the user is not already auto scrolling, which happens while dragging and

View file

@ -18,8 +18,14 @@
<template v-if="application._.selected" #body>
<ul class="tree__subs">
<SidebarItem
v-for="table in application.tables"
v-for="table in orderedTables"
:key="table.id"
v-sortable="{
id: table.id,
update: orderTables,
marginLeft: 34,
marginRight: 10,
}"
:database="application"
:table="table"
></SidebarItem>
@ -51,6 +57,13 @@ export default {
required: true,
},
},
computed: {
orderedTables() {
return this.application.tables
.map((table) => table)
.sort((a, b) => a.order - b.order)
},
},
methods: {
async selected(application) {
try {
@ -59,6 +72,17 @@ export default {
notifyIf(error, 'group')
}
},
async orderTables(order, oldOrder) {
try {
await this.$store.dispatch('table/order', {
database: this.application,
order,
oldOrder,
})
} catch (error) {
notifyIf(error, 'table')
}
},
},
}
</script>

View file

@ -1,16 +1,19 @@
<template>
<li class="tree__sub" :class="{ active: table._.selected }">
<a class="tree__sub-link" @click.prevent="selectTable(database, table)">
<Editable
ref="rename"
:value="table.name"
@change="renameTable(database, table, $event)"
></Editable>
<a class="tree__sub-link" @click="selectTable(database, table)">
<div>
<Editable
ref="rename"
:value="table.name"
@change="renameTable(database, table, $event)"
></Editable>
</div>
</a>
<a
v-show="!database._.loading"
class="tree__options"
@click="$refs.context.toggle($event.currentTarget, 'bottom', 'right', 0)"
@mousedown.stop
>
<i class="fas fa-ellipsis-v"></i>
</a>

View file

@ -24,7 +24,9 @@
:class="{ active: isTableActive(table) }"
>
<a class="tree__sub-link" @click="selectTable(application, table)">
{{ table.name }}
<div>
{{ table.name }}
</div>
</a>
</li>
</ul>

View file

@ -27,6 +27,13 @@ export const registerRealtimeEvents = (realtime) => {
}
})
realtime.registerEvent('tables_reordered', ({ store, app }, data) => {
const database = store.getters['application/get'](data.database_id)
if (database !== undefined) {
store.commit('table/ORDER_TABLES', { database, order: data.order })
}
})
realtime.registerEvent('table_deleted', ({ store }, data) => {
const database = store.getters['application/get'](data.database_id)
if (database !== undefined) {

View file

@ -17,6 +17,11 @@ export default (client) => {
update(tableId, values) {
return client.patch(`/database/tables/${tableId}/`, values)
},
order(databaseId, order) {
return client.post(`/database/tables/database/${databaseId}/order/`, {
table_ids: order,
})
},
delete(tableId) {
return client.delete(`/database/tables/${tableId}/`)
},

View file

@ -27,6 +27,12 @@ export const mutations = {
UPDATE_ITEM(state, { table, values }) {
Object.assign(table, table, values)
},
ORDER_TABLES(state, { database, order }) {
database.tables.forEach((table) => {
const index = order.findIndex((value) => value === table.id)
table.order = index === -1 ? 0 : index + 1
})
},
SET_SELECTED(state, { database, table }) {
Object.values(database.tables).forEach((item) => {
item._.selected = false
@ -107,6 +113,19 @@ export const actions = {
forceUpdate({ commit }, { database, table, values }) {
commit('UPDATE_ITEM', { database, table, values })
},
/**
* Updates the order of all the tables in a database.
*/
async order({ commit, getters }, { database, order, oldOrder }) {
commit('ORDER_TABLES', { database, order })
try {
await TableService(this.$client).order(database.id, order)
} catch (error) {
commit('ORDER_TABLES', { database, order: oldOrder })
throw error
}
},
/**
* Deletes an existing application.
*/