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:
parent
f1a759dcd0
commit
978567897a
22 changed files with 469 additions and 71 deletions
backend
src/baserow/contrib/database
api
migrations
rows
table
views
tests/baserow/contrib/database
web-frontend
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
]
|
|
@ -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():
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -476,6 +476,7 @@ class ViewHandler:
|
|||
|
||||
order_by.append(order)
|
||||
|
||||
order_by.append('order')
|
||||
order_by.append('id')
|
||||
queryset = queryset.order_by(*order_by)
|
||||
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue