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

Resolve "Add new row on a given position"

This commit is contained in:
Bram Wiepjes 2020-12-18 12:01:37 +00:00
parent f1a759dcd0
commit 978567897a
22 changed files with 469 additions and 71 deletions
backend
changelog.md
web-frontend
modules/database
components
services
store/view
utils
package.jsonyarn.lock

View file

@ -13,11 +13,10 @@ logger = logging.getLogger(__name__)
class RowSerializer(serializers.ModelSerializer):
class Meta:
fields = ('id',)
fields = ('id', 'order',)
extra_kwargs = {
'id': {
'read_only': True
}
'id': {'read_only': True},
'order': {'read_only': True}
}
@ -84,6 +83,11 @@ def get_example_row_serializer_class(add_id=False):
read_only=True,
help_text='The unique identifier of the row in the table.'
)
fields['order'] = serializers.DecimalField(
max_digits=40, decimal_places=20, required=False,
help_text='Indicates the position of the row, lowest first and highest '
'last.'
)
field_types = field_type_registry.registry.values()

View file

@ -176,7 +176,7 @@ class RowsView(APIView):
search = request.GET.get('search')
order_by = request.GET.get('order_by')
queryset = model.objects.all().enhance_by_fields().order_by('id')
queryset = model.objects.all().enhance_by_fields()
if search:
queryset = queryset.search_all_fields(search)
@ -206,6 +206,11 @@ class RowsView(APIView):
name='table_id', location=OpenApiParameter.PATH, type=OpenApiTypes.INT,
description='Creates a row in the table related to the provided '
'value.'
),
OpenApiParameter(
name='before', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description='If provided then the newly created row will be '
'positioned before the row with the provided id.'
)
],
tags=['Database table rows'],
@ -232,7 +237,8 @@ class RowsView(APIView):
]),
401: get_error_schema(['ERROR_NO_PERMISSION_TO_TABLE']),
404: get_error_schema([
'ERROR_TABLE_DOES_NOT_EXIST'
'ERROR_TABLE_DOES_NOT_EXIST',
'ERROR_ROW_DOES_NOT_EXIST'
])
}
)
@ -241,7 +247,8 @@ class RowsView(APIView):
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE,
UserFileDoesNotExist: ERROR_USER_FILE_DOES_NOT_EXIST
UserFileDoesNotExist: ERROR_USER_FILE_DOES_NOT_EXIST,
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
})
def post(self, request, table_id):
"""
@ -256,7 +263,14 @@ class RowsView(APIView):
validation_serializer = get_row_serializer_class(model)
data = validate_data(validation_serializer, request.data)
row = RowHandler().create_row(request.user, table, data, model)
before_id = request.GET.get('before')
before = (
RowHandler().get_row(request.user, table, before_id, model)
if before_id else
None
)
row = RowHandler().create_row(request.user, table, data, model, before=before)
serializer_class = get_row_serializer_class(model, RowSerializer,
is_response=True)
serializer = serializer_class(row)

View file

@ -116,7 +116,7 @@ class GridViewView(APIView):
view = view_handler.get_view(request.user, view_id, GridView)
model = view.table.get_model()
queryset = model.objects.all().enhance_by_fields().order_by('id')
queryset = model.objects.all().enhance_by_fields()
# Applies the view filters and sortings to the queryset if there are any.
queryset = view_handler.apply_filters(view, queryset)

View file

@ -0,0 +1,72 @@
# Note that if you have a lot of tables, it might table a while before this migration
# completes.
from django.conf import settings
from django.db import migrations, connections
from django.db.models import F
from baserow.contrib.database.table.models import Table as TableModel
def exists(cursor, table_id):
cursor.execute(
"""
SELECT exists(
SELECT
1
FROM
information_schema.columns
WHERE
columns.table_name = %s AND
columns.column_name = 'order'
)
""",
[f'database_table_{table_id}']
)
rows = cursor.fetchall()
return rows[0][0]
def add_to_tables(apps, schema_editor):
Table = apps.get_model('database', 'Table')
connection = connections[settings.USER_TABLE_DATABASE]
cursor = connection.cursor()
with connection.schema_editor() as tables_schema_editor:
# We need to stop the transaction because we might need to lock a lot of tables
# which could result in an out of memory exception.
tables_schema_editor.atomic.__exit__(None, None, None)
for table in Table.objects.all():
if not exists(cursor, table.id):
to_model = TableModel.get_model(table, field_ids=[])
order = to_model._meta.get_field('order')
order.default = '1'
tables_schema_editor.add_field(to_model, order)
to_model.objects.all().update(order=F('id'))
def remove_from_tables(apps, schema_editor):
Table = apps.get_model('database', 'Table')
connection = connections[settings.USER_TABLE_DATABASE]
cursor = connection.cursor()
with connection.schema_editor() as tables_schema_editor:
tables_schema_editor.atomic.__exit__(None, None, None)
for table in Table.objects.all():
if exists(cursor, table.id):
to_model = TableModel.get_model(table, field_ids=[])
order = to_model._meta.get_field('order')
tables_schema_editor.remove_field(to_model, order)
class Migration(migrations.Migration):
dependencies = [
('database', '0021_auto_20201215_2047'),
]
operations = [
migrations.RunPython(add_to_tables, remove_from_tables)
]

View file

@ -1,6 +1,9 @@
import re
from math import floor, ceil
from decimal import Decimal
from django.db import transaction
from django.db.models import Max, F
from django.db.models.fields.related import ManyToManyField
from django.conf import settings
@ -114,7 +117,7 @@ class RowHandler:
return row
def create_row(self, user, table, values=None, model=None):
def create_row(self, user, table, values=None, model=None, before=None):
"""
Creates a new row for a given table with the provided values.
@ -128,6 +131,9 @@ class RowHandler:
:param model: If a model is already generated it can be provided here to avoid
having to generate the model again.
:type model: Model
:param before: If provided the new row will be placed right before that row
instance.
:type before: Table
:raises UserNotInGroupError: When the user does not belong to the related group.
:return: The created row instance.
:rtype: Model
@ -146,6 +152,26 @@ class RowHandler:
values = self.prepare_values(model._field_objects, values)
values, manytomany_values = self.extract_manytomany_values(values, model)
if before:
# Here we calculate the order value, which indicates the position of the
# row, by subtracting a fraction of the row that it must be placed
# before. The same fraction is also going to be subtracted from the other
# rows that have been placed before. By using these fractions we don't
# have to re-order every row in the table.
change = Decimal('0.00000000000000000001')
values['order'] = before.order - change
model.objects.filter(
order__gt=floor(values['order']),
order__lte=values['order']
).update(order=F('order') - change)
else:
# Because the row is by default added as last, we have to figure out what
# the highest order is and increase that by one. Because the order of new
# rows should always be a whole number we round it up.
values['order'] = ceil(
model.objects.aggregate(max=Max('order')).get('max') or Decimal('0')
) + 1
instance = model.objects.create(**values)
for name, value in manytomany_values.items():

View file

@ -179,11 +179,11 @@ class TableHandler:
ViewHandler().create_view(user, table, GridViewType.type, name='Grid')
bulk_data = [
model(**{
model(order=index + 1, **{
f'field_{fields[index].id}': str(value)
for index, value in enumerate(row)
})
for row in data
for index, row in enumerate(data)
]
model.objects.bulk_create(bulk_data)
@ -215,8 +215,8 @@ class TableHandler:
view_handler.update_grid_view_field_options(view, field_options, fields=fields)
model = table.get_model(attribute_names=True)
model.objects.create(name='Tesla', active=True)
model.objects.create(name='Amazon', active=False)
model.objects.create(name='Tesla', active=True, order=1)
model.objects.create(name='Amazon', active=False, order=2)
def update_table(self, user, table, **kwargs):
"""

View file

@ -119,6 +119,7 @@ class TableModelQuerySet(models.QuerySet):
field_name
)
order_by.append('order')
order_by.append('id')
return self.order_by(*order_by)
@ -252,7 +253,7 @@ class Table(CreatedAndUpdatedOnMixin, OrderableMixin, models.Model):
'managed': False,
'db_table': f'database_table_{self.id}',
'app_label': app_label,
'ordering': ['id']
'ordering': ['order', 'id']
})
attrs = {
@ -266,7 +267,10 @@ class Table(CreatedAndUpdatedOnMixin, OrderableMixin, models.Model):
'_field_objects': {},
# We are using our own table model manager to implement some queryset
# helpers.
'objects': TableModelManager()
'objects': TableModelManager(),
# Indicates which position the row has.
'order': models.DecimalField(max_digits=40, decimal_places=20,
editable=False, db_index=True, default=1)
}
# Construct a query to fetch all the fields of that table.

View file

@ -476,6 +476,7 @@ class ViewHandler:
order_by.append(order)
order_by.append('order')
order_by.append('id')
queryset = queryset.order_by(*order_by)

View file

@ -152,10 +152,10 @@ def test_get_example_row_serializer_class():
len(field_type_registry.registry.values())
)
assert len(response_serializer._declared_fields) == (
len(request_serializer._declared_fields) + 1
len(request_serializer._declared_fields) + 2 # fields + id + order
)
assert len(response_serializer._declared_fields) == (
len(field_type_registry.registry.values()) + 1
len(field_type_registry.registry.values()) + 2 # fields + id + order
)
assert isinstance(response_serializer._declared_fields['id'],

View file

@ -27,10 +27,10 @@ def test_list_rows(api_client, data_fixture):
True)
model = table.get_model(attribute_names=True)
row_1 = model.objects.create(name='Product 1', price=50)
row_2 = model.objects.create(name='Product 2/3', price=100)
row_3 = model.objects.create(name='Product 3', price=150)
row_4 = model.objects.create(name='Last product', price=200)
row_1 = model.objects.create(name='Product 1', price=50, order=Decimal('1'))
row_2 = model.objects.create(name='Product 2/3', price=100, order=Decimal('2'))
row_3 = model.objects.create(name='Product 3', price=150, order=Decimal('3'))
row_4 = model.objects.create(name='Last product', price=200, order=Decimal('4'))
response = api_client.get(
reverse('api:database:rows:list', kwargs={'table_id': 999999}),
@ -83,6 +83,7 @@ def test_list_rows(api_client, data_fixture):
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][0][f'field_{field_1.id}'] == 'Product 1'
assert response_json['results'][0][f'field_{field_2.id}'] == 50
assert response_json['results'][0]['order'] == '1.00000000000000000000'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.get(
@ -311,6 +312,22 @@ def test_list_rows(api_client, data_fixture):
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][1]['id'] == row_3.id
row_2.order = Decimal('999')
row_2.save()
response = api_client.get(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['count'] == 4
assert len(response_json['results']) == 4
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][1]['id'] == row_3.id
assert response_json['results'][2]['id'] == row_4.id
assert response_json['results'][3]['id'] == row_2.id
@pytest.mark.django_db
def test_create_row(api_client, data_fixture):
@ -367,6 +384,16 @@ def test_create_row(api_client, data_fixture):
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.post(
f'{url}?before=99999',
{f'field_{text_field.id}': 'Green'},
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()['error'] == 'ERROR_ROW_DOES_NOT_EXIST'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
@ -397,6 +424,7 @@ def test_create_row(api_client, data_fixture):
assert not response_json_row_1[f'field_{number_field.id}']
assert response_json_row_1[f'field_{boolean_field.id}'] is False
assert response_json_row_1[f'field_{text_field_2.id}'] is None
assert response_json_row_1['order'] == '1.00000000000000000000'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
@ -414,6 +442,7 @@ def test_create_row(api_client, data_fixture):
assert not response_json_row_2[f'field_{number_field.id}']
assert response_json_row_2[f'field_{boolean_field.id}'] is False
assert response_json_row_2[f'field_{text_field_2.id}'] == ''
assert response_json_row_2['order'] == '2.00000000000000000000'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
@ -432,6 +461,7 @@ def test_create_row(api_client, data_fixture):
assert response_json_row_3[f'field_{number_field.id}'] == 120
assert response_json_row_3[f'field_{boolean_field.id}']
assert response_json_row_3[f'field_{text_field_2.id}'] == 'Not important'
assert response_json_row_3['order'] == '3.00000000000000000000'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
@ -450,10 +480,31 @@ def test_create_row(api_client, data_fixture):
assert response_json_row_4[f'field_{number_field.id}'] == 240
assert response_json_row_4[f'field_{boolean_field.id}']
assert response_json_row_4[f'field_{text_field_2.id}'] == ''
assert response_json_row_4['order'] == '4.00000000000000000000'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.post(
f"{url}?before={response_json_row_3['id']}",
{
f'field_{text_field.id}': 'Red',
f'field_{number_field.id}': 480,
f'field_{boolean_field.id}': False,
f'field_{text_field_2.id}': ''
},
format='json',
HTTP_AUTHORIZATION=f'Token {token.key}'
)
response_json_row_5 = response.json()
assert response.status_code == HTTP_200_OK
assert response_json_row_5[f'field_{text_field.id}'] == 'Red'
assert response_json_row_5[f'field_{number_field.id}'] == 480
assert not response_json_row_5[f'field_{boolean_field.id}']
assert response_json_row_5[f'field_{text_field_2.id}'] == ''
assert response_json_row_5['order'] == '2.99999999999999999999'
model = table.get_model()
assert model.objects.all().count() == 4
rows = model.objects.all().order_by('id')
assert model.objects.all().count() == 5
rows = model.objects.all()
row_1 = rows[0]
assert row_1.id == response_json_row_1['id']
@ -467,16 +518,23 @@ def test_create_row(api_client, data_fixture):
assert getattr(row_2, f'field_{text_field.id}') == 'white'
assert getattr(row_2, f'field_{number_field.id}') is None
assert getattr(row_2, f'field_{boolean_field.id}') is False
assert getattr(row_1, f'field_{text_field_2.id}') is None
assert getattr(row_2, f'field_{text_field_2.id}') == ''
row_3 = rows[2]
row_5 = rows[2]
assert row_5.id == response_json_row_5['id']
assert getattr(row_5, f'field_{text_field.id}') == 'Red'
assert getattr(row_5, f'field_{number_field.id}') == 480
assert getattr(row_5, f'field_{boolean_field.id}') is False
assert getattr(row_5, f'field_{text_field_2.id}') == ''
row_3 = rows[3]
assert row_3.id == response_json_row_3['id']
assert getattr(row_3, f'field_{text_field.id}') == 'Green'
assert getattr(row_3, f'field_{number_field.id}') == 120
assert getattr(row_3, f'field_{boolean_field.id}') is True
assert getattr(row_3, f'field_{text_field_2.id}') == 'Not important'
row_4 = rows[3]
row_4 = rows[4]
assert row_4.id == response_json_row_4['id']
assert getattr(row_4, f'field_{text_field.id}') == 'Purple'
assert getattr(row_4, f'field_{number_field.id}') == 240

View file

@ -72,26 +72,86 @@ def test_create_row(data_fixture):
with pytest.raises(UserNotInGroupError):
handler.create_row(user=user_2, table=table)
row = handler.create_row(user=user, table=table, values={
row_1 = handler.create_row(user=user, table=table, values={
name_field.id: 'Tesla',
speed_field.id: 240,
f'field_{price_field.id}': 59999.99,
9999: 'Must not be added'
})
assert getattr(row, f'field_{name_field.id}') == 'Tesla'
assert getattr(row, f'field_{speed_field.id}') == 240
assert getattr(row, f'field_{price_field.id}') == 59999.99
assert not getattr(row, f'field_9999', None)
row.refresh_from_db()
assert getattr(row, f'field_{name_field.id}') == 'Tesla'
assert getattr(row, f'field_{speed_field.id}') == 240
assert getattr(row, f'field_{price_field.id}') == Decimal('59999.99')
assert not getattr(row, f'field_9999', None)
assert getattr(row_1, f'field_{name_field.id}') == 'Tesla'
assert getattr(row_1, f'field_{speed_field.id}') == 240
assert getattr(row_1, f'field_{price_field.id}') == 59999.99
assert not getattr(row_1, f'field_9999', None)
assert row_1.order == 1
row_1.refresh_from_db()
assert getattr(row_1, f'field_{name_field.id}') == 'Tesla'
assert getattr(row_1, f'field_{speed_field.id}') == 240
assert getattr(row_1, f'field_{price_field.id}') == Decimal('59999.99')
assert not getattr(row_1, f'field_9999', None)
assert row_1.order == Decimal('1.00000000000000000000')
row = handler.create_row(user=user, table=table)
assert getattr(row, f'field_{name_field.id}') == 'Test'
assert not getattr(row, f'field_{speed_field.id}')
assert not getattr(row, f'field_{price_field.id}')
row_2 = handler.create_row(user=user, table=table)
assert getattr(row_2, f'field_{name_field.id}') == 'Test'
assert not getattr(row_2, f'field_{speed_field.id}')
assert not getattr(row_2, f'field_{price_field.id}')
row_1.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
row_3 = handler.create_row(user=user, table=table, before=row_2)
row_1.refresh_from_db()
row_2.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999999')
row_4 = handler.create_row(user=user, table=table, before=row_2)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999998')
assert row_4.order == Decimal('1.99999999999999999999')
row_5 = handler.create_row(user=user, table=table, before=row_3)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
row_4.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999998')
assert row_4.order == Decimal('1.99999999999999999999')
assert row_5.order == Decimal('1.99999999999999999997')
row_6 = handler.create_row(user=user, table=table, before=row_2)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
row_4.refresh_from_db()
row_5.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999997')
assert row_4.order == Decimal('1.99999999999999999998')
assert row_5.order == Decimal('1.99999999999999999996')
assert row_6.order == Decimal('1.99999999999999999999')
row_7 = handler.create_row(user, table=table, before=row_1)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
row_4.refresh_from_db()
row_5.refresh_from_db()
row_6.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999997')
assert row_4.order == Decimal('1.99999999999999999998')
assert row_5.order == Decimal('1.99999999999999999996')
assert row_6.order == Decimal('1.99999999999999999999')
assert row_7.order == Decimal('0.99999999999999999999')
with pytest.raises(ValidationError):
handler.create_row(user=user, table=table, values={
@ -99,7 +159,20 @@ def test_create_row(data_fixture):
})
model = table.get_model()
assert model.objects.all().count() == 2
rows = model.objects.all()
assert len(rows) == 7
assert rows[0].id == row_7.id
assert rows[1].id == row_1.id
assert rows[2].id == row_5.id
assert rows[3].id == row_3.id
assert rows[4].id == row_4.id
assert rows[5].id == row_6.id
assert rows[6].id == row_2.id
row_2.delete()
row_8 = handler.create_row(user, table=table)
assert row_8.order == Decimal('3.00000000000000000000')
@pytest.mark.django_db

View file

@ -2,6 +2,7 @@ import pytest
from django.db import connection
from django.conf import settings
from decimal import Decimal
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.table.models import Table
@ -85,7 +86,8 @@ def test_fill_example_table_data(data_fixture):
database = data_fixture.create_database_application(user=user)
table_handler = TableHandler()
table_handler.create_table(user, database, fill_example=True, name='Table 1')
table = table_handler.create_table(user, database, fill_example=True,
name='Table 1')
assert Table.objects.all().count() == 1
assert GridView.objects.all().count() == 1
@ -94,6 +96,13 @@ def test_fill_example_table_data(data_fixture):
assert BooleanField.objects.all().count() == 1
assert GridViewFieldOptions.objects.all().count() == 2
model = table.get_model()
results = model.objects.all()
assert len(results) == 2
assert results[0].order == Decimal('1.00000000000000000000')
assert results[1].order == Decimal('2.00000000000000000000')
@pytest.mark.django_db
def test_fill_table_with_initial_data(data_fixture):
@ -137,6 +146,10 @@ def test_fill_table_with_initial_data(data_fixture):
model = table.get_model()
results = model.objects.all()
assert results[0].order == Decimal('1.00000000000000000000')
assert results[1].order == Decimal('2.00000000000000000000')
assert results[2].order == Decimal('3.00000000000000000000')
assert getattr(results[0], f'field_{text_fields[0].id}') == '1-1'
assert getattr(results[0], f'field_{text_fields[1].id}') == '1-2'
assert getattr(results[0], f'field_{text_fields[2].id}') == '1-3'

View file

@ -1,4 +1,5 @@
import pytest
from decimal import Decimal
from unittest.mock import MagicMock
@ -27,6 +28,7 @@ def test_group_user_get_next_order(data_fixture):
@pytest.mark.django_db
def test_get_table_model(data_fixture):
default_model_fields_count = 3
table = data_fixture.create_database_table(name='Cars')
text_field = data_fixture.create_text_field(table=table, order=0, name='Color',
text_default='white')
@ -39,7 +41,7 @@ def test_get_table_model(data_fixture):
assert model.__name__ == f'Table{table.id}Model'
assert model._generated_table_model
assert model._meta.db_table == f'database_table_{table.id}'
assert len(model._meta.get_fields()) == 4 + 2 # Two date fields
assert len(model._meta.get_fields()) == 4 + default_model_fields_count
color_field = model._meta.get_field('color')
horsepower_field = model._meta.get_field('horsepower')
@ -74,7 +76,7 @@ def test_get_table_model(data_fixture):
model_2 = table.get_model(fields=[number_field], field_ids=[text_field.id],
attribute_names=True)
assert len(model_2._meta.get_fields()) == 3 + 2 # Two date fields.
assert len(model_2._meta.get_fields()) == 3 + default_model_fields_count
color_field = model_2._meta.get_field('color')
assert color_field
@ -86,7 +88,7 @@ def test_get_table_model(data_fixture):
model_3 = table.get_model()
assert model_3._meta.db_table == f'database_table_{table.id}'
assert len(model_3._meta.get_fields()) == 4 + 2 # Two date fields.
assert len(model_3._meta.get_fields()) == 4 + default_model_fields_count
field_1 = model_3._meta.get_field(f'field_{text_field.id}')
assert isinstance(field_1, models.TextField)
@ -104,7 +106,7 @@ def test_get_table_model(data_fixture):
text_default='orange')
model = table.get_model(attribute_names=True)
field_names = [f.name for f in model._meta.get_fields()]
assert len(field_names) == 5 + 2 # Two date fields.
assert len(field_names) == 5 + default_model_fields_count
assert f'{text_field.model_attribute_name}_field_{text_field.id}' in field_names
assert f'{text_field_2.model_attribute_name}_field_{text_field.id}' in field_names
@ -287,6 +289,24 @@ def test_order_by_fields_string_queryset(data_fixture):
assert results[2].id == row_4.id
assert results[3].id == row_2.id
row_5 = model.objects.create(
name='Audi',
color='Red',
price=2000,
description='Old times',
order=Decimal('0.1')
)
row_2.order = Decimal('0.1')
results = model.objects.all().order_by_fields_string(
f'{name_field.id}'
)
assert results[0].id == row_5.id
assert results[1].id == row_2.id
assert results[2].id == row_1.id
assert results[3].id == row_3.id
assert results[4].id == row_4.id
@pytest.mark.django_db
def test_filter_by_fields_object_queryset(data_fixture):

View file

@ -1,4 +1,5 @@
import pytest
from decimal import Decimal
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.views.handler import ViewHandler
@ -663,6 +664,20 @@ def test_apply_sortings(data_fixture):
row_ids = [row.id for row in rows]
assert row_ids == [row_4.id, row_5.id, row_6.id, row_1.id, row_2.id, row_3.id]
row_7 = model.objects.create(**{
f'field_{text_field.id}': 'Aaa',
f'field_{number_field.id}': 30,
f'field_{boolean_field.id}': True,
'order': Decimal('0.1')
})
sort.delete()
sort_2.delete()
rows = view_handler.apply_sorting(grid_view, model.objects.all())
row_ids = [row.id for row in rows]
assert row_ids == [row_7.id, row_1.id, row_2.id, row_3.id, row_4.id, row_5.id,
row_6.id]
@pytest.mark.django_db
def test_get_sort(data_fixture):

View file

@ -10,6 +10,7 @@
* Store updated and created timestamp for the groups, applications, tables, views,
fields and rows.
* Made the file name editable.
* Made the rows orderable and added the ability to insert a row at a given position.
## Released (2020-12-01)

View file

@ -23,8 +23,8 @@
></Editable>
<a
v-show="!renaming"
@click="$refs.rename.edit()"
class="file-field-modal__rename"
@click="$refs.rename.edit()"
>
<i class="fa fa-pen"></i>
</a>

View file

@ -287,6 +287,18 @@
</div>
<Context ref="rowContext">
<ul class="context__menu">
<li>
<a @click=";[addRow(selectedRow), $refs.rowContext.hide()]">
<i class="context__menu-icon fas fa-fw fa-arrow-up"></i>
Insert row above
</a>
</li>
<li>
<a @click=";[addRowAfter(selectedRow), $refs.rowContext.hide()]">
<i class="context__menu-icon fas fa-fw fa-arrow-down"></i>
Insert row below
</a>
</li>
<li>
<a
@click="
@ -571,7 +583,7 @@ export default {
this.$refs.scrollbars.update()
}
},
async addRow() {
async addRow(before = null) {
try {
await this.$store.dispatch('view/grid/create', {
view: this.view,
@ -579,11 +591,28 @@ export default {
// We need a list of all fields including the primary one here.
fields: [this.primary].concat(...this.fields),
values: {},
before,
})
} catch (error) {
notifyIf(error, 'row')
}
},
/**
* Because it is only possible to add a new row before another row, we have to
* figure out which row is below the given row and insert before that one. If the
* next row is not found, we can safely assume it is the last row and add it last.
*/
addRowAfter(row) {
const rows = this.$store.getters['view/grid/getAllRows']
const index = rows.findIndex((r) => r.id === row.id)
let nextRow = null
if (index !== -1 && rows.length > index + 1) {
nextRow = rows[index + 1]
}
this.addRow(nextRow)
},
showRowContext(event, row) {
this.selectedRow = row
this.$refs.rowContext.toggle(

View file

@ -14,8 +14,14 @@ export default (client) => {
return client.get(`/database/rows/table/${tableId}/`, config)
},
create(tableId, values) {
return client.post(`/database/rows/table/${tableId}/`, values)
create(tableId, values, beforeId = null) {
const config = { params: {} }
if (beforeId !== null) {
config.params.before = beforeId
}
return client.post(`/database/rows/table/${tableId}/`, values, config)
},
update(tableId, rowId, values) {
return client.patch(`/database/rows/table/${tableId}/${rowId}/`, values)

View file

@ -1,6 +1,7 @@
import Vue from 'vue'
import axios from 'axios'
import _ from 'lodash'
import BigNumber from 'bignumber.js'
import { uuid } from '@baserow/modules/core/utils/string'
import GridService from '@baserow/modules/database/services/view/grid'
@ -81,7 +82,7 @@ export const mutations = {
/**
* It will add and remove rows to the state based on the provided values. For example
* if prependToRows is a positive number that amount of the provided rows will be
* added to the state. If that number is negative that amoun will be removed from
* added to the state. If that number is negative that amount will be removed from
* the state. Same goes for the appendToRows, only then it will be appended.
*/
ADD_ROWS(
@ -109,6 +110,29 @@ export const mutations = {
)
}
},
/**
* Inserts a new row at a specific index.
*/
INSERT_ROW_AT(state, { row, index }) {
state.count++
state.bufferLimit++
const min = new BigNumber(row.order.split('.')[0])
const max = new BigNumber(row.order)
// Decrease all the orders that have already have been inserted before the same
// row.
state.rows.forEach((row) => {
const order = new BigNumber(row.order)
if (order.isGreaterThan(min) && order.isLessThanOrEqualTo(max)) {
row.order = order
.minus(new BigNumber('0.00000000000000000001'))
.toString()
}
})
state.rows.splice(index, 0, row)
},
SET_ROWS_INDEX(state, { startIndex, endIndex, top }) {
state.rowsStartIndex = startIndex
state.rowsEndIndex = endIndex
@ -137,9 +161,13 @@ export const mutations = {
state.rows.splice(index, 1)
}
},
FINALIZE_ROW(state, { index, id }) {
state.rows[index].id = id
state.rows[index]._.loading = false
FINALIZE_ROW(state, { oldId, id, order }) {
const index = state.rows.findIndex((item) => item.id === oldId)
if (index !== -1) {
state.rows[index].id = id
state.rows[index].order = order
state.rows[index]._.loading = false
}
},
SET_VALUE(state, { row, field, value }) {
row[`field_${field.id}`] = value
@ -155,7 +183,7 @@ export const mutations = {
SORT_ROWS(state, sortFunction) {
state.rows.sort(sortFunction)
// Because all the rows have been sorted again we can safely asume they are all in
// Because all the rows have been sorted again we can safely assume they are all in
// the right order again.
state.rows.forEach((row) => {
if (!row._.matchSortings) {
@ -581,7 +609,7 @@ export const actions = {
*/
updateMatchSortings(
{ commit, getters, rootGetters },
{ view, row, fields, primary, overrides = {} }
{ view, row, fields, primary = null, overrides = {} }
) {
const values = JSON.parse(JSON.stringify(row))
Object.keys(overrides).forEach((key) => {
@ -631,7 +659,7 @@ export const actions = {
*/
async create(
{ commit, getters, rootGetters, dispatch },
{ view, table, fields, values = {} }
{ view, table, fields, values = {}, before = null }
) {
// Fill the not provided values with the empty value of the field type so we can
// immediately commit the created row to the state.
@ -651,26 +679,44 @@ export const actions = {
row.id = uuid()
row._.loading = true
commit('ADD_ROWS', {
rows: [row],
prependToRows: 0,
appendToRows: 1,
count: getters.getCount + 1,
bufferStartIndex: getters.getBufferStartIndex,
bufferLimit: getters.getBufferLimit + 1,
})
if (before !== null) {
// If the row has been placed before another row we can specifically insert to
// the row at a calculated index.
const index = getters.getAllRows.findIndex((r) => r.id === before.id)
const change = new BigNumber('0.00000000000000000001')
row.order = new BigNumber(before.order).minus(change).toString()
commit('INSERT_ROW_AT', { row, index })
} else {
// By default the row is inserted at the end.
commit('ADD_ROWS', {
rows: [row],
prependToRows: 0,
appendToRows: 1,
count: getters.getCount + 1,
bufferStartIndex: getters.getBufferStartIndex,
bufferLimit: getters.getBufferLimit + 1,
})
}
// Recalculate all the values.
dispatch('visibleByScrollTop', {
scrollTop: null,
windowHeight: null,
})
const index = getters.getRowsLength - 1
// Check if the newly created row matches the filters.
dispatch('updateMatchFilters', { view, row })
// Check if the newly created row matches the sortings.
dispatch('updateMatchSortings', { view, fields, row })
try {
const { data } = await RowService(this.$client).create(table.id, values)
commit('FINALIZE_ROW', { index, id: data.id })
const { data } = await RowService(this.$client).create(
table.id,
values,
before !== null ? before.id : null
)
commit('FINALIZE_ROW', { oldId: row.id, id: data.id, order: data.order })
} catch (error) {
commit('DELETE_ROW', row.id)
throw error

View file

@ -1,14 +1,21 @@
import { firstBy } from 'thenby'
import BigNumber from 'bignumber.js'
/**
* Generates a sort function based on the provided sortings.
*/
export function getRowSortFunction($registry, sortings, fields, primary) {
export function getRowSortFunction(
$registry,
sortings,
fields,
primary = null
) {
let sortFunction = firstBy()
sortings.forEach((sort) => {
// Find the field that is related to the sort.
let field = fields.find((f) => f.id === sort.field)
if (field === undefined && primary.id === sort.field) {
if (field === undefined && primary !== null && primary.id === sort.field) {
field = primary
}
@ -20,6 +27,9 @@ export function getRowSortFunction($registry, sortings, fields, primary) {
}
})
sortFunction = sortFunction.thenBy((a, b) =>
new BigNumber(a.order).minus(new BigNumber(b.order))
)
sortFunction = sortFunction.thenBy((a, b) => a.id - b.id)
return sortFunction
}

View file

@ -18,6 +18,7 @@
"@fortawesome/fontawesome-free": "^5.13.0",
"@nuxtjs/axios": "5.8.0",
"axios": "0.19.0",
"bignumber.js": "^9.0.1",
"cookie-universal-nuxt": "2.1.3",
"cross-env": "7.0.2",
"jwt-decode": "2.2.0",

View file

@ -2329,6 +2329,11 @@ big.js@^5.2.2:
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
bignumber.js@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5"
integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==
binary-extensions@^1.0.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"