1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-15 01:28:30 +00:00

Merge branch '28-change-the-width-of-the-columns-per-grid-view' into 'develop'

Resolve "Change the width of the columns per grid view."

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2020-05-10 15:35:37 +00:00
commit 0d528b8d22
25 changed files with 950 additions and 54 deletions
backend
web-frontend/modules
core/assets/scss
base
components/views
database

View file

@ -153,3 +153,44 @@ def validate_body_custom_fields(registry, base_serializer_class=None,
return func(*args, **kwargs)
return func_wrapper
return validate_decorator
def allowed_includes(*allowed):
"""
A view method decorator that checks which allowed includes are in the GET
parameters of the request. The allowed arguments are going to be added to the
view method kwargs and if they are in the includes GET parameter the value will
be True.
Imagine this request:
# GET /page/?include=cars,unrelated_stuff,bikes
@allowed_includes('cars', 'bikes', 'planes')
def get(request, cars, bikes, planes):
cars >> True
bikes >> True
planes >> False
# GET /page/?include=planes
@allowed_includes('cars', 'bikes', 'planes')
def get(request, cars, bikes, planes):
cars >> False
bikes >> False
planes >> True
:param allowed: Should have all the allowed include values.
:type allowed: list
"""
def validate_decorator(func):
def func_wrapper(*args, **kwargs):
request = get_request(args)
raw_include = request.GET.get('includes', None)
includes = raw_include.split(',') if raw_include else []
for include in allowed:
kwargs[include] = include in includes
return func(*args, **kwargs)
return func_wrapper
return validate_decorator

View file

@ -1,2 +1,11 @@
ERROR_GRID_DOES_NOT_EXIST = ('ERROR_GRID_DOES_NOT_EXIST', 404,
'The requested grid view does not exist.')
ERROR_GRID_DOES_NOT_EXIST = (
'ERROR_GRID_DOES_NOT_EXIST',
404,
'The requested grid view does not exist.'
)
ERROR_UNRELATED_FIELD = (
'ERROR_UNRELATED_FIELD',
400,
'The field is not related to the provided grid view.'
)

View file

@ -0,0 +1,90 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from baserow.contrib.database.views.models import GridView, GridViewFieldOptions
class GridViewFieldOptionsField(serializers.Field):
default_error_messages = {
'invalid_key': _('Field option key must be numeric.'),
'invalid_value': _('Must be valid field options.')
}
def __init__(self, **kwargs):
kwargs['source'] = '*'
kwargs['read_only'] = False
super().__init__(**kwargs)
def to_internal_value(self, data):
"""
This method only validates if the provided data dict is in the correct
format. Not if the field id actually exists.
Example format:
{
FIELD_ID: {
width: 200
}
}
:param data: The data that needs to be validated.
:type data: dict
:return: The validated dict.
:rtype: dict
"""
internal = {}
for key, value in data.items():
if not (
isinstance(key, int) or (isinstance(key, str) and key.isnumeric())
):
self.fail('invalid_key')
serializer = GridViewFieldOptionsSerializer(data=value)
if not serializer.is_valid():
self.fail('invalid_value')
internal[int(key)] = serializer.data
return internal
def to_representation(self, value):
"""
If the provided value is a GridView instance we need to fetch the options from
the database. We can easily use the `get_field_options` of the GridView for
that and format the dict the way we want.
If the provided value is a dict it means the field options have already been
provided and validated once, so we can just return that value. The variant is
used when we want to validate the input.
:param value: The prepared value that needs to be serialized.
:type value: GridView or dict
:return: A dictionary containing the
:rtype: dict
"""
if isinstance(value, GridView):
# If the fields are in the context we can pass them into the
# `get_field_options` call so that they don't have to be fetched from the
# database again.
fields = self.context.get('fields')
return {
field_options.field_id:
GridViewFieldOptionsSerializer(field_options).data
for field_options in value.get_field_options(True, fields)
}
else:
return value
class GridViewSerializer(serializers.ModelSerializer):
field_options = GridViewFieldOptionsField(required=False)
class Meta:
model = GridView
fields = ('field_options',)
class GridViewFieldOptionsSerializer(serializers.ModelSerializer):
class Meta:
model = GridViewFieldOptions
fields = ('width',)

View file

@ -1,19 +1,23 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.pagination import LimitOffsetPagination
from baserow.api.v0.decorators import map_exceptions
from baserow.api.v0.decorators import map_exceptions, allowed_includes, validate_body
from baserow.api.v0.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.v0.pagination import PageNumberPagination
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.api.v0.rows.serializers import (
get_row_serializer_class, RowSerializer
)
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
from baserow.contrib.database.api.v0.views.grid.serializers import GridViewSerializer
from baserow.contrib.database.views.exceptions import (
ViewDoesNotExist, UnrelatedFieldError
)
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import GridView
from .errors import ERROR_GRID_DOES_NOT_EXIST
from .errors import ERROR_GRID_DOES_NOT_EXIST, ERROR_UNRELATED_FIELD
class GridViewView(APIView):
@ -23,11 +27,15 @@ class GridViewView(APIView):
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_GRID_DOES_NOT_EXIST
})
def get(self, request, view_id):
@allowed_includes('field_options')
def get(self, request, view_id, field_options):
"""
Lists all the rows of a grid view, paginated either by a page or offset/limit.
If the limit get parameter is provided the limit/offset pagination will be used
else the page number pagination.
Optionally the field options can also be included in the response if the the
`field_options` are provided in the includes GET parameter.
"""
view = ViewHandler().get_view(request.user, view_id, GridView)
@ -44,4 +52,38 @@ class GridViewView(APIView):
serializer_class = get_row_serializer_class(model, RowSerializer)
serializer = serializer_class(page, many=True)
return paginator.get_paginated_response(serializer.data)
response = paginator.get_paginated_response(serializer.data)
if field_options:
# The serializer has the GridViewFieldOptionsField which fetches the
# field options from the database and creates them if they don't exist,
# but when added to the context the fields don't have to be fetched from
# the database again when checking if they exist.
context = {'fields': [o['field'] for o in model._field_objects.values()]}
response.data.update(**GridViewSerializer(view, context=context).data)
return response
@map_exceptions({
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_GRID_DOES_NOT_EXIST,
UnrelatedFieldError: ERROR_UNRELATED_FIELD
})
@validate_body(GridViewSerializer)
def patch(self, request, view_id, data):
"""
Updates the field options for the provided grid view.
The following example body data will only update the width of the FIELD_ID
and leaves the others untouched.
{
FIELD_ID: {
'width': 200
}
}
"""
handler = ViewHandler()
view = handler.get_view(request.user, view_id, GridView)
handler.update_grid_view_field_options(view, data['field_options'])
return Response(GridViewSerializer(view).data)

View file

@ -42,3 +42,6 @@ class UpdateViewSerializer(serializers.ModelSerializer):
class Meta:
model = View
fields = ('name',)
extra_kwargs = {
'name': {'required': False}
}

View file

@ -61,7 +61,7 @@ class ViewsView(APIView):
table = self.get_table(request.user, table_id)
view = ViewHandler().create_view(
request.user, table, data['type'], name=data['name'])
request.user, table, data.pop('type'), **data)
serializer = view_type_registry.get_serializer(view, ViewSerializer)
return Response(serializer.data)

View file

@ -0,0 +1,42 @@
# Generated by Django 2.2.11 on 2020-05-05 12:42
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('database', '0004_auto_20200117_1157'),
]
operations = [
migrations.CreateModel(
name='GridViewFieldOptions',
fields=[
('id', models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID'
)),
('width', models.PositiveIntegerField(default=200)),
('field', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='database.Field'
)),
('grid_view', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='database.GridView'
)),
],
),
migrations.AddField(
model_name='gridview',
name='field_options',
field=models.ManyToManyField(
through='database.GridViewFieldOptions',
to='database.Field'
),
),
]

View file

@ -7,6 +7,13 @@ class ViewDoesNotExist(Exception):
"""Raised when trying to get a view that doesn't exist."""
class UnrelatedFieldError(Exception):
"""
Raised when a field is not related to the view. For example when someone tries to
update field options of a field that does not belong to the view's table.
"""
class ViewTypeAlreadyRegistered(InstanceTypeAlreadyRegistered):
pass

View file

@ -1,9 +1,10 @@
from baserow.core.exceptions import UserNotInGroupError
from baserow.core.utils import extract_allowed, set_allowed_attrs
from baserow.contrib.database.fields.models import Field
from .exceptions import ViewDoesNotExist
from .exceptions import ViewDoesNotExist, UnrelatedFieldError
from .registries import view_type_registry
from .models import View
from .models import View, GridViewFieldOptions
class ViewHandler:
@ -117,3 +118,30 @@ class ViewHandler:
raise UserNotInGroupError(user, group)
view.delete()
def update_grid_view_field_options(self, grid_view, field_options, fields=None):
"""
Updates the field options with the provided values if the field id exists in
the table related to the grid view.
:param grid_view: The grid view for which the field options need to be updated.
:type grid_view: Model
:param field_options: A dict with the field ids as the key and a dict
containing the values that need to be updated as value.
:type field_options: dict
:param fields: Optionally a list of fields can be provided so that they don't
have to be fetched again.
:type fields: None or list
"""
if not fields:
fields = Field.objects.filter(table=grid_view.table)
allowed_field_ids = [field.id for field in fields]
for field_id, options in field_options.items():
if int(field_id) not in allowed_field_ids:
raise UnrelatedFieldError(f'The field id {field_id} is not related to '
f'the grid view.')
GridViewFieldOptions.objects.update_or_create(
grid_view=grid_view, field_id=field_id, defaults=options
)

View file

@ -2,6 +2,7 @@ from django.db import models
from django.contrib.contenttypes.models import ContentType
from baserow.core.mixins import OrderableMixin, PolymorphicContentTypeMixin
from baserow.contrib.database.fields.models import Field
def get_default_view_content_type():
@ -29,4 +30,52 @@ class View(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
class GridView(View):
pass
field_options = models.ManyToManyField(Field, through='GridViewFieldOptions')
def get_field_options(self, create_if_not_exists=False, fields=None):
"""
Each field can have unique options per view. This method returns those
options per field type and can optionally create the missing ones.
:param create_if_not_exists: If true the missing GridViewFieldOptions are
going to be created. If a fields has been created at a later moment it could
be possible that they don't exist yet. If this value is True, the
missing relationships are created in that case.
:type create_if_not_exists: bool
:param fields: If all the fields related to the table of this grid view have
already been fetched, they can be provided here to avoid having to fetch
them for a second time. This is only needed if `create_if_not_exists` is
True.
:type fields: list
:return: A list of field options instances related to this grid view.
:rtype: list
"""
field_options = GridViewFieldOptions.objects.filter(grid_view=self)
if create_if_not_exists:
field_options = list(field_options)
if not fields:
fields = Field.objects.filter(table=self.table)
existing_field_ids = [options.field_id for options in field_options]
for field in fields:
if field.id not in existing_field_ids:
field_option = GridViewFieldOptions.objects.create(
grid_view=self,
field=field
)
field_options.append(field_option)
return field_options
class GridViewFieldOptions(models.Model):
grid_view = models.ForeignKey(GridView, on_delete=models.CASCADE)
field = models.ForeignKey(Field, on_delete=models.CASCADE)
# The defaults should be the same as in the `fieldCreated` of the `GridViewType`
# abstraction in the web-frontend.
width = models.PositiveIntegerField(default=200)
class Meta:
ordering = ('field_id',)

View file

@ -10,7 +10,7 @@ from rest_framework.exceptions import APIException
from rest_framework.test import APIRequestFactory
from baserow.api.v0.decorators import (
map_exceptions, validate_body, validate_body_custom_fields
map_exceptions, validate_body, validate_body_custom_fields, allowed_includes
)
from baserow.core.models import Group
from baserow.core.registry import (
@ -233,3 +233,40 @@ def test_validate_body_custom_fields():
func = MagicMock()
validate_body_custom_fields(registry)(func)(*[object, request])
def test_allowed_includes():
factory = APIRequestFactory()
request = Request(factory.get(
'/some-page/',
data={'includes': 'test_1,test_2'},
))
@allowed_includes('test_1', 'test_3')
def test_1(self, request, test_1, test_3):
assert test_1
assert not test_3
test_1(None, request)
request = Request(factory.get(
'/some-page/',
data={'includes': 'test_3'},
))
@allowed_includes('test_1', 'test_3')
def test_2(self, request, test_1, test_3):
assert not test_1
assert test_3
test_2(None, request)
request = Request(factory.get('/some-page/',))
@allowed_includes('test_1', 'test_3')
def test_3(self, request, test_1, test_3):
assert not test_1
assert not test_3
test_3(None, request)

View file

@ -4,7 +4,7 @@ from django.shortcuts import reverse
@pytest.mark.django_db
def test_list_views(api_client, data_fixture):
def test_list_rows(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1')
table = data_fixture.create_database_table(user=user)
@ -92,6 +92,7 @@ def test_list_views(api_client, data_fixture):
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
response_json = response.json()
assert response.status_code == 200
assert response_json['count'] == 4
assert response_json['previous']
assert not response_json['next']
@ -115,9 +116,11 @@ def test_list_views(api_client, data_fixture):
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
response_json = response.json()
assert response.status_code == 200
assert response_json['count'] == 4
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][1]['id'] == row_2.id
assert 'field_options' not in response_json
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.get(
@ -126,6 +129,7 @@ def test_list_views(api_client, data_fixture):
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
response_json = response.json()
assert response.status_code == 200
assert response_json['count'] == 4
assert response_json['results'][0]['id'] == row_3.id
@ -145,3 +149,162 @@ def test_list_views(api_client, data_fixture):
assert not response_json['previous']
assert not response_json['next']
assert len(response_json['results']) == 0
@pytest.mark.django_db
def test_list_rows_include_field_options(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1')
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, order=0, name='Color',
text_default='white')
grid = data_fixture.create_grid_view(table=table)
# The second field is deliberately created after the creation of the grid field
# so that the GridViewFieldOptions entry is not created. This should
# automatically be created when the page is fetched.
number_field = data_fixture.create_number_field(table=table, order=1,
name='Horsepower')
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.get(
url,
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
response_json = response.json()
assert response.status_code == 200
assert 'field_options' not in response_json
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.get(
url,
{'includes': 'field_options'},
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
response_json = response.json()
assert response.status_code == 200
assert len(response_json['field_options']) == 2
assert response_json['field_options'][str(text_field.id)]['width'] == 200
assert response_json['field_options'][str(number_field.id)]['width'] == 200
@pytest.mark.django_db
def test_patch_grid_view(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1')
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table)
unknown_field = data_fixture.create_text_field()
grid = data_fixture.create_grid_view(table=table)
# The second field is deliberately created after the creation of the grid field
# so that the GridViewFieldOptions entry is not created. This should
# automatically be created when the page is fetched.
number_field = data_fixture.create_number_field(table=table)
grid_2 = data_fixture.create_grid_view()
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.patch(
url,
{'field_options': {
text_field.id: {'width': 300}
}},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == 200
assert len(response_json['field_options']) == 2
assert response_json['field_options'][str(text_field.id)]['width'] == 300
assert response_json['field_options'][str(number_field.id)]['width'] == 200
options = grid.get_field_options()
assert len(options) == 2
assert options[0].field_id == text_field.id
assert options[0].width == 300
assert options[1].field_id == number_field.id
assert options[1].width == 200
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.patch(
url,
{'field_options': {
text_field.id: {'width': 100},
number_field.id: {'width': 500}
}},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == 200
assert len(response_json['field_options']) == 2
assert response_json['field_options'][str(text_field.id)]['width'] == 100
assert response_json['field_options'][str(number_field.id)]['width'] == 500
options = grid.get_field_options()
assert len(options) == 2
assert options[0].field_id == text_field.id
assert options[0].width == 100
assert options[1].field_id == number_field.id
assert options[1].width == 500
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.patch(
url,
{},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == 200
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.patch(
url,
{'field_options': {
'RANDOM_FIELD': 'TEST'
}},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == 400
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response_json['detail']['field_options'][0]['code'] == 'invalid_key'
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.patch(
url,
{'field_options': {
99999: {'width': 100}
}},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == 400
assert response_json['error'] == 'ERROR_UNRELATED_FIELD'
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.patch(
url,
{'field_options': {1: {'width': 'abc'}}},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == 400
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response_json['detail']['field_options'][0]['code'] == 'invalid_value'
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': 999})
response = api_client.patch(
url,
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
assert response.status_code == 404
assert response.json()['error'] == 'ERROR_GRID_DOES_NOT_EXIST'
url = reverse('api_v0:database:views:grid:list', kwargs={'view_id': grid_2.id})
response = api_client.patch(
url,
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
assert response.status_code == 400
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'

View file

@ -182,9 +182,7 @@ def test_update_view(api_client, data_fixture):
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == 400
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response.status_code == 200
url = reverse('api_v0:database:views:item', kwargs={'view_id': view.id})
response = api_client.patch(

View file

@ -4,7 +4,7 @@ from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import View, GridView
from baserow.contrib.database.views.exceptions import (
ViewTypeDoesNotExist, ViewDoesNotExist
ViewTypeDoesNotExist, ViewDoesNotExist, UnrelatedFieldError
)
@ -98,3 +98,54 @@ def test_delete_view(data_fixture):
assert View.objects.all().count() == 1
handler.delete_view(user=user, view=grid)
assert View.objects.all().count() == 0
@pytest.mark.django_db
def test_update_grid_view_field_options(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
grid_view = data_fixture.create_grid_view(table=table)
field_1 = data_fixture.create_text_field(table=table)
field_2 = data_fixture.create_text_field(table=table)
field_3 = data_fixture.create_text_field()
with pytest.raises(ValueError):
ViewHandler().update_grid_view_field_options(grid_view=grid_view, field_options={
'strange_format': {'height': 150},
})
with pytest.raises(UnrelatedFieldError):
ViewHandler().update_grid_view_field_options(grid_view=grid_view, field_options={
99999: {'width': 150},
})
with pytest.raises(UnrelatedFieldError):
ViewHandler().update_grid_view_field_options(grid_view=grid_view, field_options={
field_3.id: {'width': 150},
})
ViewHandler().update_grid_view_field_options(grid_view=grid_view, field_options={
str(field_1.id): {'width': 150},
field_2.id: {'width': 250}
})
options_4 = grid_view.get_field_options()
assert len(options_4) == 2
assert options_4[0].width == 150
assert options_4[0].field_id == field_1.id
assert options_4[1].width == 250
assert options_4[1].field_id == field_2.id
field_4 = data_fixture.create_text_field(table=table)
ViewHandler().update_grid_view_field_options(grid_view=grid_view, field_options={
field_2.id: {'width': 300},
field_4.id: {'width': 50}
})
options_4 = grid_view.get_field_options()
assert len(options_4) == 3
assert options_4[0].width == 150
assert options_4[0].field_id == field_1.id
assert options_4[1].width == 300
assert options_4[1].field_id == field_2.id
assert options_4[2].width == 50
assert options_4[2].field_id == field_4.id

View file

@ -0,0 +1,39 @@
import pytest
from django.db import models
from baserow.contrib.database.views.models import View
@pytest.mark.django_db
def test_grid_view_get_field_options(data_fixture):
table = data_fixture.create_database_table()
table_2 = data_fixture.create_database_table()
data_fixture.create_text_field(table=table_2)
field_1 = data_fixture.create_text_field(table=table)
field_2 = data_fixture.create_text_field(table=table)
grid_view = data_fixture.create_grid_view(table=table)
field_options = grid_view.get_field_options()
assert len(field_options) == 2
assert field_options[0].field_id == field_1.id
assert field_options[0].width == 200
assert field_options[1].field_id == field_2.id
assert field_options[1].width == 200
field_3 = data_fixture.create_text_field(table=table)
field_options = grid_view.get_field_options()
assert len(field_options) == 2
field_options = grid_view.get_field_options(create_if_not_exists=True)
assert len(field_options) == 3
assert field_options[0].field_id == field_1.id
assert field_options[1].field_id == field_2.id
assert field_options[2].field_id == field_3.id
field_options = grid_view.get_field_options(create_if_not_exists=False)
assert len(field_options) == 3
assert field_options[0].field_id == field_1.id
assert field_options[1].field_id == field_2.id
assert field_options[2].field_id == field_3.id

View file

@ -1,4 +1,5 @@
from baserow.contrib.database.views.models import GridView
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.views.models import GridView, GridViewFieldOptions
class ViewFixtures:
@ -12,4 +13,17 @@ class ViewFixtures:
if 'order' not in kwargs:
kwargs['order'] = 0
return GridView.objects.create(**kwargs)
grid_view = GridView.objects.create(**kwargs)
self.create_grid_view_field_options(grid_view)
return grid_view
def create_grid_view_field_options(self, grid_view, **kwargs):
return [
self.create_grid_view_field_option(grid_view, field, **kwargs)
for field in Field.objects.filter(table=grid_view.table)
]
def create_grid_view_field_option(self, grid_view, field, **kwargs):
return GridViewFieldOptions.objects.create(
grid_view=grid_view, field=field, **kwargs
)

View file

@ -37,6 +37,10 @@
margin-bottom: 40px;
}
.resizing-horizontal {
cursor: col-resize;
}
@keyframes spin {
0% {
transform: rotate(0);

View file

@ -72,6 +72,7 @@
z-index: 3;
width: 9px;
height: 26px;
cursor: col-resize;
&::before {
content: "";
@ -323,6 +324,7 @@
z-index: 2;
width: 9px;
cursor: col-resize;
&::before {
content: "";

View file

@ -4,29 +4,37 @@
ref="scrollbars"
horizontal="right"
vertical="rightBody"
:style="{ left: getLeftWidth() + 'px' }"
:style="{ left: widths.left + 'px' }"
@vertical="verticalScroll"
@horizontal="horizontalScroll"
></Scrollbars>
<div class="grid-view-left" :style="{ width: getLeftWidth() + 'px' }">
<div class="grid-view-inner" :style="{ width: getLeftWidth() + 'px' }">
<div class="grid-view-left" :style="{ width: widths.left + 'px' }">
<div class="grid-view-inner" :style="{ width: widths.left + 'px' }">
<div class="grid-view-head">
<div class="grid-view-column" style="width: 60px;"></div>
<div
class="grid-view-column"
:style="{ width: widths.leftReserved + 'px' }"
></div>
<GridViewFieldType
v-if="primary !== null"
:field="primary"
:style="{ width: widths.fields[primary.id] + 'px' }"
></GridViewFieldType>
</div>
<div ref="leftBody" class="grid-view-body">
<div class="grid-view-body-inner">
<div
class="grid-view-placeholder"
style="width: 260px;"
:style="{ height: placeholderHeight + 'px' }"
:style="{
height: placeholderHeight + 'px',
width: widths.left + 'px',
}"
>
<div
class="grid-view-placeholder-column"
style="width: 260px;"
:style="{
width: widths.left + 'px',
}"
></div>
</div>
<div
@ -40,7 +48,10 @@
:class="{ 'grid-view-row-loading': row._.loading }"
@contextmenu.prevent="showRowContext($event, row)"
>
<div class="grid-view-column" style="width: 60px;">
<div
class="grid-view-column"
:style="{ width: widths.leftReserved + 'px' }"
>
<div class="grid-view-row-info">
<div class="grid-view-row-count">{{ row.id }}</div>
<a href="#" class="grid-view-row-more">
@ -54,6 +65,7 @@
:field="primary"
:row="row"
:table="table"
:style="{ width: widths.fields[primary.id] + 'px' }"
@selected="selectedField(primary, $event.component)"
@selectNext="selectNextField(row, primary, fields, primary)"
></GridViewField>
@ -62,7 +74,7 @@
<div class="grid-view-row">
<div
class="grid-view-column"
:style="{ width: getLeftWidth() + 'px' }"
:style="{ width: widths.left + 'px' }"
>
<a
class="grid-view-add-row"
@ -78,10 +90,7 @@
</div>
</div>
<div class="grid-view-foot">
<div
class="grid-view-column"
:style="{ width: getLeftWidth() + 'px' }"
>
<div class="grid-view-column" :style="{ width: widths.left + 'px' }">
<div class="grid-view-foot-info">{{ count }} rows</div>
</div>
</div>
@ -90,24 +99,42 @@
<div
ref="divider"
class="grid-view-divider"
:style="{ left: getLeftWidth() + 'px' }"
:style="{ left: widths.left + 'px' }"
></div>
<GridViewFieldWidthHandle
class="grid-view-divider-width"
:style="{ left: widths.left + 'px' }"
:grid="view"
:field="primary"
:width="widths.fields[primary.id]"
></GridViewFieldWidthHandle>
<div
ref="right"
class="grid-view-right"
:style="{ left: getLeftWidth() + 'px' }"
:style="{ left: widths.left + 'px' }"
>
<div
class="grid-view-inner"
:style="{ 'min-width': getRightWidth() + 'px' }"
:style="{ 'min-width': widths.right + 'px' }"
>
<div class="grid-view-head">
<GridViewFieldType
v-for="field in fields"
:key="'right-head-field-' + view.id + '-' + field.id"
:field="field"
></GridViewFieldType>
<div class="grid-view-column" style="width: 100px;">
:style="{ width: widths.fields[field.id] + 'px' }"
>
<GridViewFieldWidthHandle
class="grid-view-description-width"
:grid="view"
:field="field"
:width="widths.fields[field.id]"
></GridViewFieldWidthHandle>
</GridViewFieldType>
<div
class="grid-view-column"
:style="{ width: widths.rightAdd + 'px' }"
>
<a
ref="createFieldContextLink"
class="grid-view-add-column"
@ -129,14 +156,14 @@
class="grid-view-placeholder"
:style="{
height: placeholderHeight + 'px',
width: fields.length * 200 + 'px',
width: widths.rightFieldsOnly + 'px',
}"
>
<div
v-for="(field, index) in fields"
:key="'right-placeholder-column-' + view.id + '-' + field.id"
v-for="(value, id) in widths.placeholderPositions"
:key="'right-placeholder-column-' + view.id + '-' + id"
class="grid-view-placeholder-column"
:style="{ left: (index + 1) * 200 - 1 + 'px' }"
:style="{ left: value - 1 + 'px' }"
></div>
</div>
<div
@ -160,6 +187,7 @@
:field="field"
:row="row"
:table="table"
:style="{ width: widths.fields[field.id] + 'px' }"
@selected="selectedField(field, $event.component)"
@selectPrevious="
selectNextField(row, field, fields, primary, true)
@ -171,7 +199,7 @@
<div class="grid-view-row">
<div
class="grid-view-column"
:style="{ width: getRightWidth(true) + 'px' }"
:style="{ width: widths.rightFieldsOnly + 'px' }"
>
<a
class="grid-view-add-row"
@ -206,7 +234,9 @@ import { mapGetters } from 'vuex'
import CreateFieldContext from '@baserow/modules/database/components/field/CreateFieldContext'
import GridViewFieldType from '@baserow/modules/database/components/view/grid/GridViewFieldType'
import GridViewField from '@baserow/modules/database/components/view/grid/GridViewField'
import GridViewFieldWidthHandle from '@baserow/modules/database/components/view/grid/GridViewFieldWidthHandle'
import { notifyIf } from '@baserow/modules/core/utils/error'
import _ from 'lodash'
export default {
name: 'GridView',
@ -214,6 +244,7 @@ export default {
CreateFieldContext,
GridViewFieldType,
GridViewField,
GridViewFieldWidthHandle,
},
props: {
primary: {
@ -242,6 +273,9 @@ export default {
addHover: false,
loading: true,
selectedRow: null,
widths: {
fields: {},
},
}
},
computed: {
@ -251,8 +285,28 @@ export default {
rowHeight: 'view/grid/getRowHeight',
rowsTop: 'view/grid/getRowsTop',
placeholderHeight: 'view/grid/getPlaceholderHeight',
fieldOptions: 'view/grid/getAllFieldOptions',
}),
},
watch: {
// The field options contain the widths of the field. Every time one of the values
// changes we need to recalculate all the widths.
fieldOptions: {
deep: true,
handler(value) {
this.calculateWidths(this.primary, this.fields, value)
},
},
// If a field is added or removed we need to recalculate all the widths.
fields(value) {
this.calculateWidths(this.primary, this.fields, this.fieldOptions)
},
},
created() {
// We have to calculate the widths when the component is created so that we can
// render the page properly on the server side.
this.calculateWidths(this.primary, this.fields, this.fieldOptions)
},
methods: {
scroll(pixelY, pixelX) {
const $rightBody = this.$refs.rightBody
@ -284,16 +338,75 @@ export default {
$divider.classList.toggle('shadow', canScroll && left > 0)
$right.scrollLeft = left
},
getLeftWidth() {
const space = 60
const left = 1 * 200
return space + left
/**
* Calculates the widths of all fields, left side, right side and place holder
* positions and returns the values in an object.
*/
getCalculatedWidths(primary, fields, fieldOptions) {
const getFieldWidth = (fieldId) => {
return Object.prototype.hasOwnProperty.call(fieldOptions, fieldId)
? fieldOptions[fieldId].width
: 200
}
// Calculate the widths left side of the grid view. This is the sticky side that
// contains the primary field and ids.
const leftReserved = 60
const leftFieldsOnly = getFieldWidth(primary.id)
const left = leftFieldsOnly + leftReserved
// Calculate the widths of the right side that contains all the other fields.
const rightAdd = 100
const rightReserved = 100
const rightFieldsOnly = fields.reduce(
(value, field) => getFieldWidth(field.id) + value,
0
)
const right = rightFieldsOnly + rightAdd + rightReserved
// Calculate the left positions of the placeholder columns. These are the gray
// vertical lines that are always visible, even when the data hasn't loaded yet.
let last = 0
const placeholderPositions = {}
fields.forEach((field) => {
last += getFieldWidth(field.id)
placeholderPositions[field.id] = last
})
const fieldWidths = {}
fieldWidths[primary.id] = getFieldWidth(primary.id)
fields.forEach((field) => {
fieldWidths[field.id] = getFieldWidth(field.id)
})
return {
left,
leftReserved,
leftFieldsOnly,
right,
rightReserved,
rightAdd,
rightFieldsOnly,
placeholderPositions,
fields: fieldWidths,
}
},
getRightWidth(columnsOnly = false) {
const right = this.fields.length * 200
const add = 100
const space = 100
return right + (columnsOnly ? 0 : add + space)
/**
* This method is called when the fieldOptions or fields changes. The reason why we
* don't have smaller methods that are called from the template to calculate the
* widths is that because that would quickly result in thousands of function calls
* when the smallest things change in the data. This is a speed improving
* workaround.
*/
calculateWidths(primary, fields, fieldOptions) {
_.assign(
this.widths,
this.getCalculatedWidths(primary, fields, fieldOptions)
)
if (this.$refs.scrollbars) {
this.$refs.scrollbars.update()
}
},
async addRow() {
try {

View file

@ -1,5 +1,5 @@
<template>
<div class="grid-view-column" style="width: 200px;" @click="select()">
<div class="grid-view-column" @click="select()">
<component
:is="getFieldComponent(field.type)"
ref="column"

View file

@ -1,5 +1,5 @@
<template>
<div class="grid-view-column" style="width: 200px;">
<div class="grid-view-column">
<div
class="grid-view-description"
:class="{ 'grid-view-description-loading': field._.loading }"
@ -47,6 +47,7 @@
</li>
</ul>
</Context>
<slot></slot>
</div>
</div>
</template>

View file

@ -0,0 +1,80 @@
<template>
<div @mousedown="start($event)"></div>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
name: 'GridViewFieldWidthHandle',
props: {
grid: {
type: Object,
required: true,
},
field: {
type: Object,
required: true,
},
width: {
type: Number,
required: true,
},
},
data() {
return {
dragging: false,
mouseStart: 0,
startWidth: 0,
}
},
methods: {
start(event) {
event.preventDefault()
this.dragging = true
this.mouseStart = event.clientX
this.startWidth = parseFloat(this.width)
this.$el.moveEvent = (event) => this.move(event)
this.$el.upEvent = (event) => this.up(event)
window.addEventListener('mousemove', this.$el.moveEvent)
window.addEventListener('mouseup', this.$el.upEvent)
document.body.classList.add('resizing-horizontal')
},
move(event) {
event.preventDefault()
const difference = event.clientX - this.mouseStart
const newWidth = Math.max(this.startWidth + difference, 100)
this.$store.dispatch('view/grid/setFieldOptionsOfField', {
field: this.field,
values: { width: newWidth },
})
},
async up(event) {
event.preventDefault()
const difference = event.clientX - this.mouseStart
const newWidth = Math.max(this.startWidth + difference, 100)
window.removeEventListener('mousemove', this.$el.moveEvent)
window.removeEventListener('mouseup', this.$el.upEvent)
document.body.classList.remove('resizing-horizontal')
if (newWidth === this.startWidth) {
return
}
try {
await this.$store.dispatch('view/grid/updateFieldOptionsOfField', {
gridId: this.grid.id,
field: this.field,
values: { width: newWidth },
oldValues: { width: this.startWidth },
})
} catch (error) {
notifyIf(error, 'field')
}
},
},
}
</script>

View file

@ -1,12 +1,19 @@
import { client } from '@baserow/modules/core/services/client'
export default {
fetchRows({ gridId, limit = 100, offset = null, cancelToken = null }) {
fetchRows({
gridId,
limit = 100,
offset = null,
cancelToken = null,
includeFieldOptions = false,
}) {
const config = {
params: {
limit,
},
}
const includes = []
if (offset !== null) {
config.params.offset = offset
@ -16,6 +23,17 @@ export default {
config.cancelToken = cancelToken
}
if (includeFieldOptions) {
includes.push('field_options')
}
if (includes.length > 0) {
config.params.includes = includes.join(',')
}
return client.get(`/database/views/grid/${gridId}/`, config)
},
update({ gridId, values }) {
return client.patch(`/database/views/grid/${gridId}/`, values)
},
}

View file

@ -12,6 +12,9 @@ export function populateRow(row) {
export const state = () => ({
loading: false,
loaded: false,
// Contains the custom field options per view. Things like the field width are
// stored here.
fieldOptions: {},
// Contains the buffered rows that we keep in memory. Depending on the
// scrollOffset rows will be added or removed from this buffer. Most of the times,
// it will contain 3 times the bufferRequestSize in rows.
@ -121,6 +124,18 @@ export const mutations = {
SET_ROW_LOADING(state, { row, value }) {
row._.loading = value
},
REPLACE_ALL_FIELD_OPTIONS(state, fieldOptions) {
state.fieldOptions = fieldOptions
},
SET_FIELD_OPTIONS_OF_FIELD(state, { fieldId, values }) {
if (Object.prototype.hasOwnProperty.call(state.fieldOptions, fieldId)) {
_.assign(state.fieldOptions[fieldId], values)
} else {
state.fieldOptions = _.assign({}, state.fieldOptions, {
[fieldId]: values,
})
}
},
}
// Contains the timeout needed for the delayed delayed scroll top action.
@ -380,6 +395,7 @@ export const actions = {
gridId,
offset: 0,
limit,
includeFieldOptions: true,
})
data.results.forEach((part, index) => {
populateRow(data.results[index])
@ -399,6 +415,7 @@ export const actions = {
endIndex: data.count > 31 ? 31 : data.count,
top: 0,
})
commit('REPLACE_ALL_FIELD_OPTIONS', data.field_options)
},
/**
* Updates a grid view field value. It will immediately be updated in the store
@ -497,6 +514,41 @@ export const actions = {
addField({ commit }, { field, value = null }) {
commit('ADD_FIELD', { field, value })
},
/**
* Updates the field options of a given field and also makes an API request to the
* backend with the changed values. If the request fails the action is reverted.
*/
async updateFieldOptionsOfField(
{ commit },
{ gridId, field, values, oldValues }
) {
commit('SET_FIELD_OPTIONS_OF_FIELD', {
fieldId: field.id,
values,
})
const updateValues = { field_options: {} }
updateValues.field_options[field.id] = values
try {
await GridService.update({ gridId, values: updateValues })
} catch (error) {
commit('SET_FIELD_OPTIONS_OF_FIELD', {
fieldId: field.id,
values: oldValues,
})
throw error
}
},
/**
* Updates the field options of a given field in the store. So no API request to
* the backend is made.
*/
setFieldOptionsOfField({ commit }, { field, values }) {
commit('SET_FIELD_OPTIONS_OF_FIELD', {
fieldId: field.id,
values,
})
},
}
export const getters = {
@ -551,6 +603,9 @@ export const getters = {
getWindowHeight(state) {
return state.windowHeight
},
getAllFieldOptions(state) {
return state.fieldOptions
},
}
export default {

View file

@ -121,5 +121,15 @@ export class GridViewType extends ViewType {
fieldCreated({ dispatch }, table, field, fieldType) {
const value = fieldType.getEmptyValue(field)
dispatch('view/grid/addField', { field, value }, { root: true })
dispatch(
'view/grid/setFieldOptionsOfField',
{
field,
// The default values should be the same as in the `GridViewFieldOptions`
// model in the backend to stay consistent.
values: { width: 200 },
},
{ root: true }
)
}
}