diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index 22821bc10..bc96ffff0 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -3,3 +3,4 @@ django-cors-headers==3.0.2 djangorestframework==3.9.4 djangorestframework-jwt==1.11.0 psycopg2==2.8.3 +ipython==7.7.0 diff --git a/backend/src/baserow/api/v0/groups/serializers.py b/backend/src/baserow/api/v0/groups/serializers.py new file mode 100644 index 000000000..4cbb355ba --- /dev/null +++ b/backend/src/baserow/api/v0/groups/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from baserow.core.models import Group, GroupUser + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ('id', 'name',) + extra_kwargs = { + 'id': { + 'read_only': True + } + } + + +class GroupUserSerializer(serializers.ModelSerializer): + class Meta: + model = GroupUser + fields = ('order',) + + def to_representation(self, instance): + data = super().to_representation(instance) + data.update(GroupSerializer(instance.group).data) + return data + + +class OrderGroupsSerializer(serializers.Serializer): + groups = serializers.ListField(child=serializers.IntegerField()) diff --git a/backend/src/baserow/api/v0/groups/urls.py b/backend/src/baserow/api/v0/groups/urls.py new file mode 100644 index 000000000..ce4c5a984 --- /dev/null +++ b/backend/src/baserow/api/v0/groups/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url + +from .views import GroupsView, GroupView, GroupOrderView + + +app_name = 'baserow.api.v0.group' + +urlpatterns = [ + url(r'^$', GroupsView.as_view(), name='list'), + url(r'(?P<group_id>[0-9]+)/$', GroupView.as_view(), name='item'), + url(r'order/$', GroupOrderView.as_view(), name='order') +] diff --git a/backend/src/baserow/api/v0/groups/views.py b/backend/src/baserow/api/v0/groups/views.py new file mode 100644 index 000000000..3167910ac --- /dev/null +++ b/backend/src/baserow/api/v0/groups/views.py @@ -0,0 +1,73 @@ +from django.shortcuts import get_object_or_404 +from django.db import transaction + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from baserow.core.models import GroupUser +from baserow.core.handler import CoreHandler + +from .serializers import GroupSerializer, GroupUserSerializer, OrderGroupsSerializer + + +class GroupsView(APIView): + permission_classes = (IsAuthenticated,) + core_handler = CoreHandler() + + def get(self, request): + """Responds with a list of groups where the users takes part in.""" + groups = GroupUser.objects.filter(user=request.user).select_related('group') + serializer = GroupUserSerializer(groups, many=True) + return Response(serializer.data) + + @transaction.atomic + def post(self, request): + """Creates a new group for a user.""" + serializer = GroupSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = serializer.data + group_user = self.core_handler.create_group(request.user, name=data['name']) + + return Response(GroupUserSerializer(group_user).data) + + +class GroupView(APIView): + permission_classes = (IsAuthenticated,) + core_handler = CoreHandler() + + @transaction.atomic + def patch(self, request, group_id): + """Updates the group if it belongs to a user.""" + group_user = get_object_or_404(GroupUser, group_id=group_id, user=request.user) + + serializer = GroupSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = serializer.data + group_user.group = self.core_handler.update_group( + request.user, group_user.group, name=data['name']) + + return Response(GroupUserSerializer(group_user).data) + + @transaction.atomic + def delete(self, request, group_id): + """Deletes an existing group if it belongs to a user.""" + group_user = get_object_or_404(GroupUser, group_id=group_id, user=request.user) + self.core_handler.delete_group(request.user, group_user.group) + return Response(status=204) + + +class GroupOrderView(APIView): + permission_classes = (IsAuthenticated,) + core_handler = CoreHandler() + + def post(self, request): + """Updates to order of some groups for a user.""" + serializer = OrderGroupsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + self.core_handler.order_groups(request.user, serializer.data['groups']) + + return Response(status=204) diff --git a/backend/src/baserow/api/v0/urls.py b/backend/src/baserow/api/v0/urls.py index 6f5a4a39d..e434d41b2 100644 --- a/backend/src/baserow/api/v0/urls.py +++ b/backend/src/baserow/api/v0/urls.py @@ -1,10 +1,12 @@ from django.urls import path, include from .user import urls as user_urls +from .groups import urls as group_urls app_name = 'baserow.api.v0' urlpatterns = [ - path('user/', include(user_urls, namespace='user')) + path('user/', include(user_urls, namespace='user')), + path('groups/', include(group_urls, namespace='groups')) ] diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 5a0e2b881..aae81bca5 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -25,6 +25,7 @@ INSTALLED_APPS = [ 'rest_framework', 'corsheaders', + 'baserow.core', 'baserow.api.v0' ] diff --git a/backend/src/baserow/config/urls.py b/backend/src/baserow/config/urls.py index 4d6cc869c..6de44ae02 100644 --- a/backend/src/baserow/config/urls.py +++ b/backend/src/baserow/config/urls.py @@ -3,5 +3,5 @@ from django.conf.urls import url urlpatterns = [ - url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api')), + url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api_v0')), ] diff --git a/backend/src/baserow/core/__init__.py b/backend/src/baserow/core/__init__.py new file mode 100644 index 000000000..1a371c919 --- /dev/null +++ b/backend/src/baserow/core/__init__.py @@ -0,0 +1 @@ +app_name = 'baserow.group' diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py new file mode 100644 index 000000000..b2ef0f46f --- /dev/null +++ b/backend/src/baserow/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'baserow.core' diff --git a/backend/src/baserow/core/exceptions.py b/backend/src/baserow/core/exceptions.py new file mode 100644 index 000000000..6a8d1ffa0 --- /dev/null +++ b/backend/src/baserow/core/exceptions.py @@ -0,0 +1,2 @@ +class UserNotIngroupError(Exception): + pass diff --git a/backend/src/baserow/core/handler.py b/backend/src/baserow/core/handler.py new file mode 100644 index 000000000..cb8071b98 --- /dev/null +++ b/backend/src/baserow/core/handler.py @@ -0,0 +1,79 @@ +from .models import Group, GroupUser +from .exceptions import UserNotIngroupError + + +class CoreHandler: + def create_group(self, user, **kwargs): + """Creates a new group for an existing user. + + :param user: The user that must be in the group. + :type user: User + :return: The newly created GroupUser object + :rtype: GroupUser + """ + + allowed_fields = ['name'] + + group_values = {} + for field in allowed_fields: + if field in allowed_fields: + group_values[field] = kwargs[field] + + group = Group.objects.create(**group_values) + last_order = GroupUser.get_last_order(user) + group_user = GroupUser.objects.create(group=group, user=user, order=last_order) + + return group_user + + def update_group(self, user, group, **kwargs): + """Updates fields of a group. + + :param user: + :param group: + :return: + """ + + if not group.has_user(user): + raise UserNotIngroupError(f'The user {user} does not belong to the group ' + f'{group}.') + + allowed_fields = ['name'] + + for field in allowed_fields: + if field in kwargs: + setattr(group, field, kwargs[field]) + + group.save() + + return group + + def delete_group(self, user, group): + """Deletes an existing group. + + :param user: + :type: user: User + :param group: + :type: group: Group + :return: + """ + + if not group.has_user(user): + raise UserNotIngroupError(f'The user {user} does not belong to the group ' + f'{group}.') + + group.delete() + + def order_groups(self, user, group_ids): + """Changes the order of groups for a user. + + :param user: + :type: user: User + :param group_ids: A list of group ids ordered the way they need to be ordered. + :type group_ids: List[int] + """ + + for index, group_id in enumerate(group_ids): + GroupUser.objects.filter( + user=user, + group_id=group_id + ).update(order=index + 1) diff --git a/backend/src/baserow/core/managers.py b/backend/src/baserow/core/managers.py new file mode 100644 index 000000000..edde479fe --- /dev/null +++ b/backend/src/baserow/core/managers.py @@ -0,0 +1,8 @@ +from django.db import models + + +class GroupQuerySet(models.QuerySet): + def of_user(self, user): + return self.filter( + users__exact=user + ).order_by('groupuser__order') diff --git a/backend/src/baserow/core/migrations/0001_initial.py b/backend/src/baserow/core/migrations/0001_initial.py new file mode 100644 index 000000000..6bb6214dd --- /dev/null +++ b/backend/src/baserow/core/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 2.2.2 on 2019-07-28 13:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='GroupUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField()), + ('group', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.Group')), + ('user', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL + )), + ], + options={ + 'ordering': ('order',), + }, + ), + migrations.AddField( + model_name='group', + name='users', + field=models.ManyToManyField(through='core.GroupUser', + to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/web-frontend/components/.gitkeep b/backend/src/baserow/core/migrations/__init__.py similarity index 100% rename from web-frontend/components/.gitkeep rename to backend/src/baserow/core/migrations/__init__.py diff --git a/backend/src/baserow/core/models.py b/backend/src/baserow/core/models.py new file mode 100644 index 000000000..b4e833b4c --- /dev/null +++ b/backend/src/baserow/core/models.py @@ -0,0 +1,41 @@ +from django.db import models +from django.contrib.auth import get_user_model + +from .managers import GroupQuerySet + + +User = get_user_model() + + +class Group(models.Model): + name = models.CharField(max_length=100) + users = models.ManyToManyField(User, through='GroupUser') + + objects = GroupQuerySet.as_manager() + + def has_user(self, user): + """Returns true is the user belongs to the group.""" + return self.users.filter(id=user.id).exists() + + def __str__(self): + return f'<Group id={self.id}, name={self.name}>' + + +class GroupUser(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + group = models.ForeignKey(Group, on_delete=models.CASCADE) + order = models.PositiveIntegerField() + + class Meta: + ordering = ('order',) + + @classmethod + def get_last_order(cls, user): + """Returns a new position that will be last for a new group.""" + highest_order = cls.objects.filter( + user=user + ).aggregate( + models.Max('order') + ).get('order__max', 0) or 0 + + return highest_order + 1 diff --git a/backend/tests/baserow/api/v0/group/test_group_views.py b/backend/tests/baserow/api/v0/group/test_group_views.py new file mode 100644 index 000000000..a5aeb1af8 --- /dev/null +++ b/backend/tests/baserow/api/v0/group/test_group_views.py @@ -0,0 +1,121 @@ +import pytest + +from django.shortcuts import reverse + +from baserow.core.models import Group, GroupUser + + +@pytest.mark.django_db +def test_list_groups(api_client, data_fixture): + user, token = data_fixture.create_user_and_token( + email='test@test.nl', password='password', first_name='Test1') + group_2 = data_fixture.create_user_group(user=user, order=2) + group_1 = data_fixture.create_user_group(user=user, order=1) + + response = api_client.get(reverse('api_v0:groups:list'), **{ + 'HTTP_AUTHORIZATION': f'JWT {token}' + }) + assert response.status_code == 200 + response_json = response.json() + assert response_json[0]['id'] == group_1.id + assert response_json[0]['order'] == 1 + assert response_json[1]['id'] == group_2.id + assert response_json[1]['order'] == 2 + + +@pytest.mark.django_db +def test_create_group(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + response = api_client.post(reverse('api_v0:groups:list'), { + 'name': 'Test 1' + }, format='json', HTTP_AUTHORIZATION=f'JWT {token}') + assert response.status_code == 200 + json_response = response.json() + group_user = GroupUser.objects.filter(user=user.id).first() + assert group_user.order == 1 + assert group_user.order == json_response['order'] + assert group_user.group.id == json_response['id'] + assert group_user.group.name == 'Test 1' + assert group_user.user == user + + response = api_client.post(reverse('api_v0:groups:list'), { + 'not_a_name': 'Test 1' + }, format='json', HTTP_AUTHORIZATION=f'JWT {token}') + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_update_group(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + group = data_fixture.create_group(user=user, name='Old name') + + url = reverse('api_v0:groups:item', kwargs={'group_id': 99999}) + response = api_client.patch( + url, + {'name': 'New name'}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + assert response.status_code == 404 + + url = reverse('api_v0:groups:item', kwargs={'group_id': group.id}) + response = api_client.patch( + url, + {'name': 'New name'}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + assert response.status_code == 200 + json_response = response.json() + + group.refresh_from_db() + + assert group.name == 'New name' + assert json_response['id'] == group.id + assert json_response['name'] == 'New name' + + +@pytest.mark.django_db +def test_delete_group(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + group = data_fixture.create_group(user=user, name='Old name') + + url = reverse('api_v0:groups:item', kwargs={'group_id': 99999}) + response = api_client.delete( + url, + HTTP_AUTHORIZATION=f'JWT {token}' + ) + assert response.status_code == 404 + + url = reverse('api_v0:groups:item', kwargs={'group_id': group.id}) + response = api_client.delete( + url, + HTTP_AUTHORIZATION=f'JWT {token}' + ) + assert response.status_code == 204 + assert Group.objects.all().count() == 0 + + +@pytest.mark.django_db +def test_reorder_groups(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + group_user_1 = data_fixture.create_user_group(user=user) + group_user_2 = data_fixture.create_user_group(user=user) + group_user_3 = data_fixture.create_user_group(user=user) + + url = reverse('api_v0:groups:order') + response = api_client.post( + url, + {'groups': [group_user_2.group.id, group_user_1.group.id, + group_user_3.group.id]}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + assert response.status_code == 204 + + group_user_1.refresh_from_db() + group_user_2.refresh_from_db() + group_user_3.refresh_from_db() + + assert [1, 2, 3] == [group_user_2.order, group_user_1.order, group_user_3.order] diff --git a/backend/tests/baserow/api/v0/user/test_token_auth.py b/backend/tests/baserow/api/v0/user/test_token_auth.py index 740ef960d..c9f5a45fd 100644 --- a/backend/tests/baserow/api/v0/user/test_token_auth.py +++ b/backend/tests/baserow/api/v0/user/test_token_auth.py @@ -16,32 +16,32 @@ User = get_user_model() @pytest.mark.django_db -def test_token_auth(client, data_fixture): +def test_token_auth(api_client, data_fixture): data_fixture.create_user(email='test@test.nl', password='password', first_name='Test1') - response = client.post(reverse('api:user:token_auth'), { + response = api_client.post(reverse('api_v0:user:token_auth'), { 'username': 'no_existing@test.nl', 'password': 'password' - }) + }, format='json') json = response.json() assert response.status_code == 400 assert len(json['non_field_errors']) > 0 - response = client.post(reverse('api:user:token_auth'), { + response = api_client.post(reverse('api_v0:user:token_auth'), { 'username': 'test@test.nl', 'password': 'wrong_password' - }) + }, format='json') json = response.json() assert response.status_code == 400 assert len(json['non_field_errors']) > 0 - response = client.post(reverse('api:user:token_auth'), { + response = api_client.post(reverse('api_v0:user:token_auth'), { 'username': 'test@test.nl', 'password': 'password' - }) + }, format='json') json = response.json() assert response.status_code == 200 @@ -52,17 +52,16 @@ def test_token_auth(client, data_fixture): @pytest.mark.django_db -def test_token_refresh(client, data_fixture): - user = data_fixture.create_user(email='test@test.nl', password='password', - first_name='Test1') +def test_token_refresh(api_client, data_fixture): + user, token = data_fixture.create_user_and_token( + email='test@test.nl', password='password', first_name='Test1') - response = client.post(reverse('api:user:token_refresh'), {'token': 'WRONG_TOKEN'}) + response = api_client.post(reverse('api_v0:user:token_refresh'), + {'token': 'WRONG_TOKEN'}, format='json') assert response.status_code == 400 - payload = jwt_payload_handler(user) - token = jwt_encode_handler(payload) - - response = client.post(reverse('api:user:token_refresh'), {'token': token}) + response = api_client.post(reverse('api_v0:user:token_refresh'), + {'token': token}, format='json') assert response.status_code == 200 assert 'token' in response.json() @@ -71,5 +70,6 @@ def test_token_refresh(client, data_fixture): payload = jwt_payload_handler(user) token = jwt_encode_handler(payload) - response = client.post(reverse('api:user:token_refresh'), {'token': token}) + response = api_client.post(reverse('api_v0:user:token_refresh'), + json={'token': token}) assert response.status_code == 400 diff --git a/backend/tests/baserow/api/v0/user/test_user_views.py b/backend/tests/baserow/api/v0/user/test_user_views.py index 25f9ba952..c0cde03c4 100644 --- a/backend/tests/baserow/api/v0/user/test_user_views.py +++ b/backend/tests/baserow/api/v0/user/test_user_views.py @@ -9,11 +9,11 @@ User = get_user_model() @pytest.mark.django_db def test_create_user(client): - response = client.post(reverse('api:user:index'), { + response = client.post(reverse('api_v0:user:index'), { 'name': 'Test1', 'email': 'test@test.nl', 'password': 'test12' - }) + }, format='json') assert response.status_code == 200 user = User.objects.get(email='test@test.nl') @@ -21,17 +21,17 @@ def test_create_user(client): assert user.email == 'test@test.nl' assert user.password != '' - response_failed = client.post(reverse('api:user:index'), { + response_failed = client.post(reverse('api_v0:user:index'), { 'name': 'Test1', 'email': 'test@test.nl', 'password': 'test12' - }) + }, format='json') assert response_failed.status_code == 400 assert response_failed.json()['error'] == 'ERROR_ALREADY_EXISTS' - response_failed_2 = client.post(reverse('api:user:index'), { + response_failed_2 = client.post(reverse('api_v0:user:index'), { 'email': 'test' - }) + }, format='json') assert response_failed_2.status_code == 400 diff --git a/backend/tests/baserow/core/test_core_handler.py b/backend/tests/baserow/core/test_core_handler.py new file mode 100644 index 000000000..8d69d57cc --- /dev/null +++ b/backend/tests/baserow/core/test_core_handler.py @@ -0,0 +1,94 @@ +import pytest + +from baserow.core.handler import CoreHandler +from baserow.core.models import Group, GroupUser +from baserow.core.exceptions import UserNotIngroupError + + +@pytest.mark.django_db +def test_create_group(data_fixture): + user = data_fixture.create_user() + + handler = CoreHandler() + handler.create_group(user=user, name='Test group') + + group = Group.objects.all().first() + user_group = GroupUser.objects.all().first() + + assert group.name == 'Test group' + assert user_group.user == user + assert user_group.group == group + assert user_group.order == 1 + + handler.create_group(user=user, name='Test group 2') + + assert Group.objects.all().count() == 2 + assert GroupUser.objects.all().count() == 2 + + +@pytest.mark.django_db +def test_update_group(data_fixture): + user_1 = data_fixture.create_user() + user_2 = data_fixture.create_user() + group = data_fixture.create_group(user=user_1) + + handler = CoreHandler() + handler.update_group(user=user_1, group=group, name='New name') + + group.refresh_from_db() + + assert group.name == 'New name' + + with pytest.raises(UserNotIngroupError): + handler.update_group(user=user_2, group=group, name='New name') + + +@pytest.mark.django_db +def test_delete_group(data_fixture): + user = data_fixture.create_user() + group_1 = data_fixture.create_group(user=user) + group_2 = data_fixture.create_group(user=user) + + user_2 = data_fixture.create_user() + group_3 = data_fixture.create_group(user=user_2) + + handler = CoreHandler() + handler.delete_group(user, group_1) + + assert Group.objects.all().count() == 2 + assert GroupUser.objects.all().count() == 2 + + with pytest.raises(UserNotIngroupError): + handler.delete_group(user, group_3) + + handler.delete_group(user_2, group_3) + + assert Group.objects.all().count() == 1 + assert GroupUser.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_order_groups(data_fixture): + user = data_fixture.create_user() + ug_1 = data_fixture.create_user_group(user=user, order=1) + ug_2 = data_fixture.create_user_group(user=user, order=2) + ug_3 = data_fixture.create_user_group(user=user, order=3) + + assert [1, 2, 3] == [ug_1.order, ug_2.order, ug_3.order] + + handler = CoreHandler() + handler.order_groups(user, [ug_3.id, ug_2.id, ug_1.id]) + + ug_1.refresh_from_db() + ug_2.refresh_from_db() + ug_3.refresh_from_db() + + assert [1, 2, 3] == [ug_3.order, ug_2.order, ug_1.order] + + handler.order_groups(user, [ug_2.id, ug_1.id, ug_3.id]) + + ug_1.refresh_from_db() + ug_2.refresh_from_db() + ug_3.refresh_from_db() + + assert [1, 2, 3] == [ug_2.order, ug_1.order, ug_3.order] diff --git a/backend/tests/baserow/core/test_core_managers.py b/backend/tests/baserow/core/test_core_managers.py new file mode 100644 index 000000000..aeea1da2d --- /dev/null +++ b/backend/tests/baserow/core/test_core_managers.py @@ -0,0 +1,24 @@ +import pytest + +from baserow.core.models import Group + + +@pytest.mark.django_db +def test_groups_of_user(data_fixture): + user_1 = data_fixture.create_user() + group_user_1 = data_fixture.create_user_group(user=user_1, order=1) + group_user_2 = data_fixture.create_user_group(user=user_1, order=2) + group_user_3 = data_fixture.create_user_group(user=user_1, order=0) + + user_2 = data_fixture.create_user() + group_user_4 = data_fixture.create_user_group(user=user_2, order=0) + + groups_user_1 = Group.objects.of_user(user=user_1) + assert len(groups_user_1) == 3 + assert groups_user_1[0].id == group_user_3.id + assert groups_user_1[1].id == group_user_1.id + assert groups_user_1[2].id == group_user_2.id + + groups_user_2 = Group.objects.of_user(user=user_2) + assert len(groups_user_2) == 1 + assert groups_user_2[0].id == group_user_4.id diff --git a/backend/tests/baserow/core/test_core_models.py b/backend/tests/baserow/core/test_core_models.py new file mode 100644 index 000000000..4abcf464d --- /dev/null +++ b/backend/tests/baserow/core/test_core_models.py @@ -0,0 +1,26 @@ +import pytest + +from baserow.core.models import GroupUser + + +@pytest.mark.django_db +def test_group_user_get_next_order(data_fixture): + user = data_fixture.create_user() + + assert GroupUser.get_last_order(user) == 1 + + group_user_1 = data_fixture.create_user_group(order=0) + group_user_2_1 = data_fixture.create_user_group(order=10) + group_user_2_2 = data_fixture.create_user_group(user=group_user_2_1.user, order=11) + + assert GroupUser.get_last_order(group_user_1.user) == 1 + assert GroupUser.get_last_order(group_user_2_1.user) == 12 + + +@pytest.mark.django_db +def test_group_has_user(data_fixture): + user = data_fixture.create_user() + user_group = data_fixture.create_user_group() + + assert user_group.group.has_user(user_group.user) + assert not user_group.group.has_user(user) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f6224f5b4..15dce07ff 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,3 +5,9 @@ import pytest def data_fixture(): from .fixtures import Fixtures return Fixtures() + + +@pytest.fixture() +def api_client(): + from rest_framework.test import APIClient + return APIClient() diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py index 1b2f9b50e..13a51aaef 100644 --- a/backend/tests/fixtures/__init__.py +++ b/backend/tests/fixtures/__init__.py @@ -1,5 +1,8 @@ +from faker import Faker + from .user import UserFixtures +from .group import GroupFixtures -class Fixtures(UserFixtures): - pass +class Fixtures(UserFixtures, GroupFixtures): + fake = Faker() diff --git a/backend/tests/fixtures/group.py b/backend/tests/fixtures/group.py new file mode 100644 index 000000000..bcb3d2fec --- /dev/null +++ b/backend/tests/fixtures/group.py @@ -0,0 +1,32 @@ +from baserow.core.models import Group, GroupUser + + +class GroupFixtures: + def create_group(self, **kwargs): + user = kwargs.pop('user', None) + users = kwargs.pop('users', []) + + if user: + users.insert(0, user) + + if 'name' not in kwargs: + kwargs['name'] = self.fake.name() + + group = Group.objects.create(**kwargs) + + for user in users: + self.create_user_group(group=group, user=user, order=0) + + return group + + def create_user_group(self, **kwargs): + if 'group' not in kwargs: + kwargs['group'] = self.create_group() + + if 'user' not in kwargs: + kwargs['user'] = self.create_user() + + if 'order' not in kwargs: + kwargs['order'] = 0 + + return GroupUser.objects.create(**kwargs) diff --git a/backend/tests/fixtures/user.py b/backend/tests/fixtures/user.py index 2b58a1c68..d6b5b8e48 100644 --- a/backend/tests/fixtures/user.py +++ b/backend/tests/fixtures/user.py @@ -1,20 +1,39 @@ from django.contrib.auth import get_user_model -from faker import Faker + +from rest_framework_jwt.settings import api_settings -fake = Faker() User = get_user_model() +jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER +jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER class UserFixtures: + def generate_token(self, user): + payload = jwt_payload_handler(user) + token = jwt_encode_handler(payload) + return token + def create_user(self, **kwargs): - kwargs.setdefault('email', fake.email()) - kwargs.setdefault('username', kwargs['email']) - kwargs.setdefault('first_name', fake.name()) - kwargs.setdefault('password', 'password') + if 'email' not in kwargs: + kwargs['email'] = self.fake.email() + + if 'username' not in kwargs: + kwargs['username'] = kwargs['email'] + + if 'first_name' not in kwargs: + kwargs['first_name'] = self.fake.name() + + if 'password' not in kwargs: + kwargs['password'] = 'password' user = User(**kwargs) user.set_password(kwargs['password']) user.save() return user + + def create_user_and_token(self, **kwargs): + user = self.create_user(**kwargs) + token = self.generate_token(user) + return user, token diff --git a/start_osx.sh b/start_osx.sh new file mode 100755 index 000000000..a5ed3151f --- /dev/null +++ b/start_osx.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +tabname() { + printf "\e]1;$1\a" +} + +new_tab() { + TAB_NAME=$1 + COMMAND=$2 + CONTAINER_COMMAND=$3 + + osascript \ + -e "tell application \"Terminal\"" \ + -e "tell application \"System Events\" to keystroke \"t\" using {command down}" \ + -e "do script \"printf '\\\e]1;$TAB_NAME\\\a'; $COMMAND\" in front window" \ + -e "do script \"$CONTAINER_COMMAND\" in front window" \ + -e "end tell" > /dev/null +} + +docker-compose up -d + +new_tab "Backend" \ + "docker exec -it baserow bash" \ + "cd backend; python src/baserow/manage.py runserver 0.0.0.0:8000" + +new_tab "Web frontend" \ + "docker exec -it baserow bash" \ + "cd web-frontend; yarn run dev" + +new_tab "Web frontend eslint" \ + "docker exec -it baserow bash" \ + "cd web-frontend; yarn run eslint --fix" + +new_tab "Old web frontend" \ + "docker exec -it baserow bash" \ + "cd old-web-frontend; yarn run dev" diff --git a/stop_osx.sh b/stop_osx.sh new file mode 100755 index 000000000..eabe86329 --- /dev/null +++ b/stop_osx.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose kill diff --git a/web-frontend/assets/scss/abstracts/_mixins.scss b/web-frontend/assets/scss/abstracts/_mixins.scss index f17260724..e4fa6f735 100644 --- a/web-frontend/assets/scss/abstracts/_mixins.scss +++ b/web-frontend/assets/scss/abstracts/_mixins.scss @@ -39,6 +39,14 @@ .alert-content { color: $text; } + + .alert-close { + color: $title; + + &:hover { + color: $text; + } + } } @mixin fixed-height($height, $font-size) { @@ -54,6 +62,18 @@ text-align: center; } +@mixin loading($size: 1.4em) { + position: relative; + display: block; + width: $size; + height: $size; + border-radius: 50%; + border: 0.25em solid; + border-color: $color-primary-500 transparent $color-primary-500 transparent; + animation: spin infinite 1800ms; + animation-timing-function: cubic-bezier(0.785, 0.135, 0.15, 0.86); +} + // The basically works the same as padding: 10px 0 0 10px or padding: 10px; only then // resulting position: absolute; with corresponding top, right, bottom, left values. // ------- diff --git a/web-frontend/assets/scss/abstracts/_variables.scss b/web-frontend/assets/scss/abstracts/_variables.scss index e1812d75d..b59188bf3 100644 --- a/web-frontend/assets/scss/abstracts/_variables.scss +++ b/web-frontend/assets/scss/abstracts/_variables.scss @@ -63,4 +63,15 @@ $z-index-layout-col-2: 2; $z-index-layout-col-3: 1; $z-index-layout-col-3-1: 5; $z-index-layout-col-3-2: 4; -$z-index-modal: 6; + +// The z-index of modal and context must always be the same because they can be nested +// inside each other. The order in html decided which must be shown over the other. +$z-index-modal: 7; +$z-index-context: 7; + +// The notifications will be on top of anything else, because the message is temporary +// and must always be visible. +$z-index-notifications: 8; + +// normalize overrides +$base-font-family: $text-font-stack; diff --git a/web-frontend/assets/scss/base/_base.scss b/web-frontend/assets/scss/base/_base.scss index 52b9444b5..1501f6d02 100644 --- a/web-frontend/assets/scss/base/_base.scss +++ b/web-frontend/assets/scss/base/_base.scss @@ -11,6 +11,10 @@ body { background-color: $color-neutral-100; } +a { + cursor: pointer; +} + *, *::before, *::after { diff --git a/web-frontend/assets/scss/base/_helpers.scss b/web-frontend/assets/scss/base/_helpers.scss index 8e74fb1e2..878ef3703 100644 --- a/web-frontend/assets/scss/base/_helpers.scss +++ b/web-frontend/assets/scss/base/_helpers.scss @@ -8,6 +8,10 @@ display: none; } +.visibility-hidden { + visibility: hidden; +} + .align-right { text-align: right; } diff --git a/web-frontend/assets/scss/components/_alert.scss b/web-frontend/assets/scss/components/_alert.scss index 3f36ec74e..68af751fc 100644 --- a/web-frontend/assets/scss/components/_alert.scss +++ b/web-frontend/assets/scss/components/_alert.scss @@ -10,6 +10,10 @@ position: relative; padding-left: 72px; } + + &.alert-with-shadow { + box-shadow: 0 2px 6px 0 rgba($black, 0.16); + } } .alert-icon { @@ -35,6 +39,10 @@ margin: 0; } +.alert-close { + @include absolute(10px, 10px, auto, auto); +} + .alert-success { @include alert-style($color-success-100, $color-success-300, $color-success-900, $color-success-700, $color-success-400); } diff --git a/web-frontend/assets/scss/components/_context.scss b/web-frontend/assets/scss/components/_context.scss index b3114459b..f95ce3106 100644 --- a/web-frontend/assets/scss/components/_context.scss +++ b/web-frontend/assets/scss/components/_context.scss @@ -1,6 +1,6 @@ .context { position: absolute; - z-index: 1; + z-index: $z-index-context; white-space: nowrap; background-color: $white; border-radius: 6px; @@ -8,6 +8,17 @@ box-shadow: 0 2px 6px 0 rgba($black, 0.16); } +.context-loading { + display: flex; + justify-content: center; + padding: 32px 0; +} + +.context-description { + padding: 32px 0; + text-align: center; +} + .context-menu-title { color: $color-neutral-600; padding: 12px 8px 2px 8px; diff --git a/web-frontend/assets/scss/components/_form.scss b/web-frontend/assets/scss/components/_form.scss index 8e9db3bbe..66ad07731 100644 --- a/web-frontend/assets/scss/components/_form.scss +++ b/web-frontend/assets/scss/components/_form.scss @@ -57,6 +57,10 @@ > div { width: 100%; } + + &.actions-right { + justify-content: right; + } } .action-links { diff --git a/web-frontend/assets/scss/components/_loading.scss b/web-frontend/assets/scss/components/_loading.scss new file mode 100644 index 000000000..6bff294a5 --- /dev/null +++ b/web-frontend/assets/scss/components/_loading.scss @@ -0,0 +1,3 @@ +.loading { + @include loading(); +} diff --git a/web-frontend/assets/scss/components/_notifications.scss b/web-frontend/assets/scss/components/_notifications.scss new file mode 100644 index 000000000..b9411f7ae --- /dev/null +++ b/web-frontend/assets/scss/components/_notifications.scss @@ -0,0 +1,7 @@ +.notifications { + position: fixed; + top: 0; + right: 30px; + width: 320px; + z-index: $z-index-notifications; +} diff --git a/web-frontend/assets/scss/components/_select.scss b/web-frontend/assets/scss/components/_select.scss index f441f6e8b..bdda6739e 100644 --- a/web-frontend/assets/scss/components/_select.scss +++ b/web-frontend/assets/scss/components/_select.scss @@ -82,6 +82,17 @@ display: none; } } + + &.select-item-loading { + background-color: $color-neutral-100; + + &::before { + content: " "; + + @include loading(14px); + @include absolute(9px, 9px, auto, auto); + } + } } .select-item-link { @@ -114,7 +125,7 @@ color: $color-neutral-700; } - :hover > & { + :not(.select-item-loading):hover > & { display: block; } } diff --git a/web-frontend/assets/scss/default.scss b/web-frontend/assets/scss/default.scss index a1ad1f74e..087151fe7 100755 --- a/web-frontend/assets/scss/default.scss +++ b/web-frontend/assets/scss/default.scss @@ -26,3 +26,5 @@ @import 'components/views/grid/boolean'; @import 'components/views/grid/number'; @import 'components/box_page'; +@import 'components/loading'; +@import 'components/notifications'; diff --git a/web-frontend/components/Context.vue b/web-frontend/components/Context.vue new file mode 100644 index 000000000..6fb984bba --- /dev/null +++ b/web-frontend/components/Context.vue @@ -0,0 +1,184 @@ +<template> + <div class="context" :class="{ 'visibility-hidden': !open }"> + <slot></slot> + </div> +</template> + +<script> +import { isElement } from '@/utils/dom' +import MoveToBody from '@/mixins/moveToBody' + +export default { + name: 'Context', + mixins: [MoveToBody], + data() { + return { + open: false, + opener: null, + children: [] + } + }, + methods: { + /** + * Toggles the open state of the context menu. + * + * @param target The original element element that changed the state of the + * context, this will be used to calculate the correct position. + * @param vertical Bottom positions the context under the target. + * Top positions the context above the target. + * Over-bottom positions the context over and under the target. + * Over-top positions the context over and above the target + * @param horizontal Left aligns the context with the left side of the target. + * Right aligns the context with the right side of the target. + * @param offset The distance between the target element and the context. + * @param value True if context must be shown, false if not and undefine + * will invert the current state. + */ + toggle( + target, + vertical = 'bottom', + horizontal = 'left', + offset = 10, + value + ) { + if (value === undefined) { + value = !this.open + } + + if (value) { + this.show(target, vertical, horizontal, offset) + } else { + this.hide() + } + }, + /** + * Calculate the position, show the context menu and register a click event on the + * body to check if the user has clicked outside the context. + */ + show(target, vertical, horizontal, offset) { + const css = this.calculatePosition(target, vertical, horizontal, offset) + + // Set the calculated positions of the context. + for (const key in css) { + const value = css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto' + this.$el.style[key] = value + } + + // If we store the element who opened the context menu we can exclude the element + // when clicked outside of this element. + this.opener = target + this.open = true + + this.$el.clickOutsideEvent = event => { + if ( + // Check if the context menu is still open + this.open && + // If the click was outside the context element because we want to ignore + // clicks inside it.s + !isElement(this.$el, event.target) && + // If the click was not on the opener because he can trigger the toggle + // method. + !isElement(this.opener, event.target) && + // If the click was not inside one of the context children of this context + // menu. + !this.children.some(component => + isElement(component.$el, event.target) + ) + ) { + this.hide() + } + } + document.body.addEventListener('click', this.$el.clickOutsideEvent) + }, + /** + * Hide the context menu and make sure the body event is removed. + */ + hide() { + this.opener = null + this.open = false + + document.body.removeEventListener('click', this.$el.clickOutsideEvent) + }, + /** + * Calculates the absolute position of the context based on the original clicked + * element. + */ + calculatePosition(target, vertical, horizontal, offset) { + const targetRect = target.getBoundingClientRect() + const contextRect = this.$el.getBoundingClientRect() + const positions = { top: null, right: null, bottom: null, left: null } + + // Calculate if top, bottom, left and right positions are possible. + const canTop = targetRect.top - contextRect.height - offset > 0 + const canBottom = + window.innerHeight - targetRect.bottom - contextRect.height - offset > 0 + const canOverTop = targetRect.bottom - contextRect.height - offset > 0 + const canOverBottom = + window.innerHeight - targetRect.bottom - contextRect.height - offset > 0 + const canRight = targetRect.right - contextRect.width > 0 + const canLeft = + window.innerWidth - targetRect.left - contextRect.width > 0 + + // If bottom, top, left or right doesn't fit, but their opposite does we switch to + // that. + if (vertical === 'bottom' && !canBottom && canTop) { + vertical = 'top' + } + + if (vertical === 'top' && !canTop) { + vertical = 'bottom' + } + + if (vertical === 'over-bottom' && !canOverBottom && canOverTop) { + vertical = 'over-top' + } + + if (vertical === 'over-top' && !canOverTop) { + vertical = 'over-bottom' + } + + if (horizontal === 'left' && !canLeft && canRight) { + horizontal = 'right' + } + + if (horizontal === 'right' && !canRight) { + horizontal = 'left' + } + + // Calculate the correct positions for horizontal and vertical values. + if (horizontal === 'left') { + positions.left = targetRect.left + } + + if (horizontal === 'right') { + positions.right = window.innerWidth - targetRect.right + } + + if (vertical === 'bottom') { + positions.top = targetRect.bottom + offset + } + + if (vertical === 'top') { + positions.bottom = window.innerHeight - targetRect.top + offset + } + + if (vertical === 'over-bottom') { + positions.top = targetRect.top + offset + } + + if (vertical === 'over-top') { + positions.bottom = window.innerHeight - targetRect.bottom + offset + } + + return positions + }, + /** + * A child context can register itself with the parent to prevent closing of the + * parent when clicked inside the child. + */ + registerContextChild(element) { + this.children.push(element) + } + } +} +</script> diff --git a/web-frontend/components/Editable.vue b/web-frontend/components/Editable.vue new file mode 100644 index 000000000..bd02769aa --- /dev/null +++ b/web-frontend/components/Editable.vue @@ -0,0 +1,103 @@ +<template> + <span + ref="editable" + :contenteditable="editing" + @input="update" + @keydown="keydown" + @focusout="change" + @paste="paste" + ></span> +</template> + +<script> +import { focusEnd } from '@/utils/dom' + +export default { + name: 'Editable', + props: { + value: { + type: String, + required: true + } + }, + data() { + return { + editing: false, + oldValue: '', + newValue: '' + } + }, + mounted() { + this.set(this.value) + }, + methods: { + /** + * This method must be called when the is going to be edited. It will enable the + * contenteditable state and will focus the element. + */ + edit() { + this.editing = true + this.$nextTick(() => { + focusEnd(this.$refs.editable) + }) + }, + /** + * This method is called when the value has changed and needs to be saved. It will + * change the editing state and will emit a change event if the new value has + * changed. + */ + change() { + this.editing = false + + if (this.oldValue === this.newValue) { + return + } + + this.$emit('change', { + oldValue: this.value, + value: this.newValue + }) + this.oldValue = this.newValue + }, + /** + * Everytime a key is pressed inside the editable this event will be trigger which + * will update the new value. + */ + update(event) { + const target = event.target + const text = target.textContent + this.newValue = text + }, + /** + * When someone pastes something we want to only insert the plain text instead of + * the styled content. + */ + paste(event) { + event.preventDefault() + const text = (event.originalEvent || event).clipboardData.getData( + 'text/plain' + ) + document.execCommand('insertHTML', false, text) + }, + /** + * If a key is pressed and it is an enter or esc key the change event will be called + * to end the editing and save the value. + */ + keydown(event) { + if (event.keyCode === 13 || event.keyCode === 27) { + event.preventDefault() + this.change() + return false + } + }, + /** + * + */ + set(value) { + this.oldValue = this.value + this.newValue = this.value + this.$refs.editable.innerText = this.value + } + } +} +</script> diff --git a/web-frontend/components/Modal.vue b/web-frontend/components/Modal.vue new file mode 100644 index 000000000..67784f817 --- /dev/null +++ b/web-frontend/components/Modal.vue @@ -0,0 +1,81 @@ +<template> + <transition name="fade"> + <div + v-if="open" + ref="modalWrapper" + class="modal-wrapper" + @click="outside($event)" + > + <div class="modal-box"> + <a class="modal-close" @click="hide()"> + <i class="fas fa-times"></i> + </a> + <slot></slot> + </div> + </div> + </transition> +</template> + +<script> +import MoveToBody from '@/mixins/moveToBody' + +export default { + name: 'Modal', + mixins: [MoveToBody], + data() { + return { + open: false + } + }, + destroyed() { + window.removeEventListener('keyup', this.keyup) + }, + methods: { + /** + * Toggle the open state of the modal. + */ + toggle(value) { + if (value === undefined) { + value = !this.open + } + + if (value) { + this.show() + } else { + this.hide() + } + }, + /** + * Show the modal. + */ + show() { + this.open = true + window.addEventListener('keyup', this.keyup) + }, + /** + * Hide the modal. + */ + hide() { + this.open = false + window.removeEventListener('keyup', this.keyup) + }, + /** + * If someone actually clicked on the modal wrapper and not one of his children the + * modal should be closed. + */ + outside(event) { + if (event.target === this.$refs.modalWrapper) { + this.hide() + } + }, + /** + * + */ + keyup(event) { + if (event.keyCode === 27) { + this.hide() + } + } + } +} +</script> diff --git a/web-frontend/components/group/CreateGroupModal.vue b/web-frontend/components/group/CreateGroupModal.vue new file mode 100644 index 000000000..70de9d35a --- /dev/null +++ b/web-frontend/components/group/CreateGroupModal.vue @@ -0,0 +1,49 @@ +<template> + <Modal> + <h2 class="box-title">Create new group</h2> + <GroupForm ref="groupForm" @submitted="submitted"> + <div class="actions"> + <div class="align-right"> + <button + class="button button-large" + :class="{ 'button-loading': loading }" + :disabled="loading" + > + Add group + </button> + </div> + </div> + </GroupForm> + </Modal> +</template> + +<script> +import GroupForm from './GroupForm' + +import modal from '@/mixins/modal' + +export default { + name: 'CreateGroupModal', + components: { GroupForm }, + mixins: [modal], + data() { + return { + loading: false + } + }, + methods: { + submitted(values) { + this.loading = true + this.$store + .dispatch('group/create', values) + .then(() => { + this.loading = false + this.hide() + }) + .catch(() => { + this.loading = false + }) + } + } +} +</script> diff --git a/web-frontend/components/group/GroupForm.vue b/web-frontend/components/group/GroupForm.vue new file mode 100644 index 000000000..ad46180e6 --- /dev/null +++ b/web-frontend/components/group/GroupForm.vue @@ -0,0 +1,47 @@ +<template> + <form @submit.prevent="submit"> + <div class="control"> + <label class="control-label"> + <i class="fas fa-font"></i> + Name + </label> + <div class="control-elements"> + <input + ref="name" + v-model="values.name" + :class="{ 'input-error': $v.values.name.$error }" + type="text" + class="input input-large" + @blur="$v.values.name.$touch()" + /> + <div v-if="$v.values.name.$error" class="error"> + This field is required. + </div> + </div> + </div> + <slot></slot> + </form> +</template> + +<script> +import { required } from 'vuelidate/lib/validators' + +import form from '@/mixins/form' + +export default { + name: 'GroupForm', + mixins: [form], + data() { + return { + values: { + name: '' + } + } + }, + validations: { + values: { + name: { required } + } + } +} +</script> diff --git a/web-frontend/components/group/GroupsContext.vue b/web-frontend/components/group/GroupsContext.vue new file mode 100644 index 000000000..6982a5a46 --- /dev/null +++ b/web-frontend/components/group/GroupsContext.vue @@ -0,0 +1,154 @@ +<template> + <Context class="select"> + <div class="select-search"> + <i class="select-search-icon fas fa-search"></i> + <input + v-model="query" + type="text" + class="select-search-input" + placeholder="Search views" + /> + </div> + <div v-if="isLoading" class="context-loading"> + <div class="loading"></div> + </div> + <ul v-if="!isLoading && isLoaded && groups.length > 0" class="select-items"> + <li + v-for="group in searchAndSort(groups)" + :key="group.id" + :ref="'groupSelect' + group.id" + class="select-item" + > + <div class="loading-overlay"></div> + <a class="select-item-link"> + <Editable + :ref="'groupRename' + group.id" + :value="group.name" + @change="renameGroup(group, $event)" + ></Editable> + </a> + <a + :ref="'groupOptions' + group.id" + class="select-item-options" + @click="toggleContext(group.id)" + > + <i class="fas fa-ellipsis-v"></i> + </a> + </li> + </ul> + <div + v-if="!isLoading && isLoaded && groups.length == 0" + class="context-description" + > + No results found + </div> + <Context ref="groupsItemContext"> + <ul class="context-menu"> + <li> + <a @click="toggleRename(contextId)"> + <i class="context-menu-icon fas fa-fw fa-pen"></i> + Rename group + </a> + </li> + <li> + <a @click="deleteGroup(contextId)"> + <i class="context-menu-icon fas fa-fw fa-trash"></i> + Delete group + </a> + </li> + </ul> + </Context> + <div class="select-footer"> + <a class="select-footer-button" @click="$refs.createGroupModal.show()"> + <i class="fas fa-plus"></i> + Create group + </a> + </div> + <CreateGroupModal ref="createGroupModal"></CreateGroupModal> + </Context> +</template> + +<script> +import { mapGetters, mapState } from 'vuex' + +import CreateGroupModal from '@/components/group/CreateGroupModal' +import context from '@/mixins/context' + +export default { + name: 'GroupsItemContext', + components: { + CreateGroupModal + }, + mixins: [context], + data() { + return { + query: '', + contextId: -1 + } + }, + computed: { + ...mapState({ + groups: state => state.group.items + }), + ...mapGetters({ + isLoading: 'group/isLoading', + isLoaded: 'group/isLoaded' + }) + }, + methods: { + toggle(...args) { + this.$store.dispatch('group/loadAll') + this.getRootContext().toggle(...args) + }, + toggleContext(groupId) { + const target = this.$refs['groupOptions' + groupId][0] + this.contextId = groupId + this.$refs.groupsItemContext.toggle(target, 'bottom', 'right', 0) + }, + searchAndSort(groups) { + const query = this.query + + return groups.filter(function(group) { + const regex = new RegExp('(' + query + ')', 'i') + return group.name.match(regex) + }) + // .sort((a, b) => { + // return a.order - b.order + // }) + }, + toggleRename(id) { + this.$refs.groupsItemContext.hide() + this.$refs['groupRename' + id][0].edit() + }, + renameGroup(group, event) { + const select = this.$refs['groupSelect' + group.id][0] + select.classList.add('select-item-loading') + + this.$store + .dispatch('group/update', { + id: group.id, + values: { + name: event.value + } + }) + .catch(() => { + // If something is going wrong we will reset the original value + const rename = this.$refs['groupRename' + group.id][0] + rename.set(event.oldValue) + }) + .then(() => { + select.classList.remove('select-item-loading') + }) + }, + deleteGroup(id) { + this.$refs.groupsItemContext.hide() + const select = this.$refs['groupSelect' + id][0] + select.classList.add('select-item-loading') + + this.$store.dispatch('group/delete', id).catch(() => { + select.classList.remove('select-item-loading') + }) + } + } +} +</script> diff --git a/web-frontend/components/notifications/Notification.vue b/web-frontend/components/notifications/Notification.vue new file mode 100644 index 000000000..b35cb6ffc --- /dev/null +++ b/web-frontend/components/notifications/Notification.vue @@ -0,0 +1,42 @@ +<template> + <div + class="alert alert-with-shadow alert-has-icon" + :class="notificationClass" + > + <a class="alert-close" @click="close(notification)"> + <i class="fas fa-times"></i> + </a> + <div class="alert-icon"> + <i class="fas fa-exclamation"></i> + </div> + <div class="alert-title">{{ notification.title }}</div> + <p class="alert-content">{{ notification.message }}</p> + </div> +</template> + +<script> +export default { + name: 'Notification', + props: { + notification: { + type: Object, + required: true + } + }, + computed: { + notificationClass() { + return 'alert-' + this.notification.type + } + }, + mounted() { + setTimeout(() => { + this.close(this.notification) + }, 5000) + }, + methods: { + close(notification) { + this.$store.dispatch('notification/remove', notification) + } + } +} +</script> diff --git a/web-frontend/components/notifications/Notifications.vue b/web-frontend/components/notifications/Notifications.vue new file mode 100644 index 000000000..78926df8b --- /dev/null +++ b/web-frontend/components/notifications/Notifications.vue @@ -0,0 +1,25 @@ +<template> + <div class="notifications"> + <Notification + v-for="notification in notifications" + :key="notification.id" + :notification="notification" + ></Notification> + </div> +</template> + +<script> +import { mapState } from 'vuex' + +import Notification from '@/components/notifications/Notification' + +export default { + name: 'Notifications', + components: { Notification }, + computed: { + ...mapState({ + notifications: state => state.notification.items + }) + } +} +</script> diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js index 5bdb09e02..2e7bc2122 100644 --- a/web-frontend/config/nuxt.config.base.js +++ b/web-frontend/config/nuxt.config.base.js @@ -25,7 +25,12 @@ export default { /* ** Plugins to load before mounting the App */ - plugins: [{ src: '@/plugins/auth.js' }, { src: '@/plugins/vuelidate.js' }], + plugins: [ + { src: '@/plugins/global.js' }, + { src: '@/plugins/client.js' }, + { src: '@/plugins/auth.js' }, + { src: '@/plugins/vuelidate.js' } + ], /* ** Nuxt.js modules diff --git a/web-frontend/intellij-idea.webpack.config.js b/web-frontend/intellij-idea.webpack.config.js new file mode 100644 index 000000000..a2fe6fbfc --- /dev/null +++ b/web-frontend/intellij-idea.webpack.config.js @@ -0,0 +1,16 @@ +/** This file can be used in combination with intellij idea so the @ path resolves **/ + +const path = require('path') + +module.exports = { + resolve: { + extensions: ['.js', '.json', '.vue', '.ts'], + root: path.resolve(__dirname), + alias: { + '@': path.resolve(__dirname), + '@@': path.resolve(__dirname), + '~': path.resolve(__dirname), + '~~': path.resolve(__dirname) + } + } +} diff --git a/web-frontend/layouts/app.vue b/web-frontend/layouts/app.vue new file mode 100644 index 000000000..b6593b1f7 --- /dev/null +++ b/web-frontend/layouts/app.vue @@ -0,0 +1,106 @@ +<template> + <div> + <Notifications></Notifications> + <div :class="{ 'layout-collapsed': isCollapsed }" class="layout"> + <div class="layout-col-1 menu"> + <ul class="menu-items"> + <li class="menu-item"> + <nuxt-link :to="{ name: 'app' }" class="menu-link"> + <i class="fas fa-tachometer-alt"></i> + <span class="menu-link-text">Dashboard</span> + </nuxt-link> + </li> + <li class="menu-item"> + <a + ref="groupSelectToggle" + class="menu-link" + @click="$refs.groupSelect.toggle($refs.groupSelectToggle)" + > + <i class="fas fa-layer-group"></i> + <span class="menu-link-text">Groups</span> + </a> + <GroupsContext ref="groupSelect"></GroupsContext> + </li> + </ul> + <ul class="menu-items"> + <li class="menu-item layout-uncollapse"> + <a class="menu-link" @click="toggleCollapsed()"> + <i class="menu-item-icon fas fa-angle-double-right"></i> + <span class="menu-link-text">Uncollapse</span> + </a> + </li> + <li class="menu-item"> + <a + class="menu-link menu-user-item" + @click="$refs.userContext.toggle($event.target)" + > + {{ nameAbbreviation }} + <span class="menu-link-text">{{ name }}</span> + </a> + <Context ref="userContext"> + <div class="context-menu-title">{{ name }}</div> + <ul class="context-menu"> + <li> + <a @click="logoff()"> + <i class="context-menu-icon fas fa-fw fa-sign-out-alt"></i> + Logoff + </a> + </li> + </ul> + </Context> + </li> + </ul> + </div> + <div class="layout-col-2 sidebar"> + <div class="sidebar-content-wrapper"> + <nav class="sidebar-content"> + <div class="sidebar-title"> + <img src="@/static/img/logo.svg" alt="" /> + </div> + </nav> + </div> + <div class="sidebar-footer"> + <a class="sidebar-collapse" @click="toggleCollapsed()"> + <i class="fas fa-angle-double-left"></i> + Collapse sidebar + </a> + </div> + </div> + <div class="layout-col-3"> + <nuxt /> + </div> + </div> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex' + +import Notifications from '@/components/notifications/Notifications' +import GroupsContext from '@/components/group/GroupsContext' + +export default { + layout: 'default', + middleware: 'authenticated', + components: { + GroupsContext, + Notifications + }, + computed: { + ...mapGetters({ + isCollapsed: 'sidebar/isCollapsed', + name: 'auth/getName', + nameAbbreviation: 'auth/getNameAbbreviation' + }) + }, + methods: { + logoff() { + this.$store.dispatch('auth/logoff') + this.$nuxt.$router.replace({ name: 'login' }) + }, + ...mapActions({ + toggleCollapsed: 'sidebar/toggleCollapsed' + }) + } +} +</script> diff --git a/web-frontend/layouts/default.vue b/web-frontend/layouts/default.vue deleted file mode 100644 index 984257528..000000000 --- a/web-frontend/layouts/default.vue +++ /dev/null @@ -1,5 +0,0 @@ -<template> - <div> - <nuxt /> - </div> -</template> diff --git a/web-frontend/layouts/login.vue b/web-frontend/layouts/login.vue index 26a13a8d9..417806a42 100644 --- a/web-frontend/layouts/login.vue +++ b/web-frontend/layouts/login.vue @@ -1,5 +1,6 @@ <template> <div> + <Notifications></Notifications> <div class="box-page-header"></div> <div class="box-page"> <div class="box login-page login-page-login"> @@ -8,3 +9,11 @@ </div> </div> </template> + +<script> +import Notifications from '@/components/notifications/Notifications' + +export default { + components: { Notifications } +} +</script> diff --git a/web-frontend/mixins/context.js b/web-frontend/mixins/context.js new file mode 100644 index 000000000..784338d0f --- /dev/null +++ b/web-frontend/mixins/context.js @@ -0,0 +1,25 @@ +/** + * This mixin is for components that have the Context component as root element. + * It will make it easier to call the root context specific functions. + */ +export default { + methods: { + getRootContext() { + if ( + this.$children.length > 0 && + this.$children[0].$options.name === 'Context' + ) { + return this.$children[0] + } + }, + toggle(...args) { + this.getRootModal().toggle(...args) + }, + show(...args) { + this.getRootModal().show(...args) + }, + hide(...args) { + this.getRootModal().hide(...args) + } + } +} diff --git a/web-frontend/mixins/form.js b/web-frontend/mixins/form.js new file mode 100644 index 000000000..e71c3c6ca --- /dev/null +++ b/web-frontend/mixins/form.js @@ -0,0 +1,34 @@ +/** + * This mixin introduces some helper functions for form components where the + * whole component existence is based on being a form. + */ +export default { + props: { + defaultValues: { + type: Object, + required: false, + default: () => { + return {} + } + } + }, + mounted() { + Object.assign(this.values, this.values, this.defaultValues) + }, + methods: { + submit() { + this.$v.$touch() + if (!this.$v.$invalid) { + this.$emit('submitted', this.values) + } + }, + reset() { + Object.assign( + this.values, + this.$options.data.call(this).values, + this.defaultValues + ) + this.$v.$reset() + } + } +} diff --git a/web-frontend/mixins/modal.js b/web-frontend/mixins/modal.js new file mode 100644 index 000000000..ef7da4efd --- /dev/null +++ b/web-frontend/mixins/modal.js @@ -0,0 +1,25 @@ +/** + * This mixin is for components that have the Modal component as root element. + * It will make it easier to call the root modal specific functions. + */ +export default { + methods: { + getRootModal() { + if ( + this.$children.length > 0 && + this.$children[0].$options.name === 'Modal' + ) { + return this.$children[0] + } + }, + toggle(...args) { + this.getRootModal().toggle(...args) + }, + show(...args) { + this.getRootModal().show(...args) + }, + hide(...args) { + this.getRootModal().hide(...args) + } + } +} diff --git a/web-frontend/mixins/moveToBody.js b/web-frontend/mixins/moveToBody.js new file mode 100644 index 000000000..c5f715f2c --- /dev/null +++ b/web-frontend/mixins/moveToBody.js @@ -0,0 +1,32 @@ +export default { + /** + * Because we don't want the parent context to close when a user clicks 'outside' that + * element and in the child element we need to register the child with their parent to + * prevent this. + */ + mounted() { + let $parent = this.$parent + while ($parent !== undefined) { + if ($parent.registerContextChild) { + $parent.registerContextChild(this) + } + $parent = $parent.$parent + } + + // Move the rendered element to the top of the body so it can be positioned over any + // other element. + const body = document.body + body.insertBefore(this.$el, body.firstChild) + }, + /** + * Make sure the context menu is not open and all the events on the body are removed + * and that the element is removed from the body. + */ + destroyed() { + this.hide() + + if (this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el) + } + } +} diff --git a/web-frontend/package.json b/web-frontend/package.json index ca7438f21..0abf5e240 100644 --- a/web-frontend/package.json +++ b/web-frontend/package.json @@ -46,6 +46,7 @@ "eslint-plugin-vue": "^5.2.2", "jest": "^24.1.0", "jsdom": "^15.1.1", + "jsdom-global": "^3.0.2", "moxios": "^0.4.0", "node-mocks-http": "^1.7.6", "nodemon": "^1.18.9", diff --git a/web-frontend/pages/app/index.vue b/web-frontend/pages/app/index.vue index d0a7363c8..5b4df3975 100644 --- a/web-frontend/pages/app/index.vue +++ b/web-frontend/pages/app/index.vue @@ -1,6 +1,9 @@ <template> <div> <h1>Welcome {{ user }}</h1> + <p> + {{ groups }} + </p> </div> </template> @@ -8,10 +11,11 @@ import { mapState } from 'vuex' export default { - middleware: 'authenticated', + layout: 'app', computed: { ...mapState({ - user: state => state.auth.user + user: state => state.auth.user, + groups: state => state.group.items }) } } diff --git a/web-frontend/pages/login/index.vue b/web-frontend/pages/login/index.vue index 91ebb00fb..73403b6be 100644 --- a/web-frontend/pages/login/index.vue +++ b/web-frontend/pages/login/index.vue @@ -56,6 +56,7 @@ <button :class="{ 'button-loading': loading }" class="button button-large" + :disabled="loading" > Sign in <i class="fas fa-lock-open"></i> @@ -104,11 +105,14 @@ export default { .then(() => { this.$nuxt.$router.replace({ name: 'app' }) }) - .catch(() => { - this.invalid = true - this.credentials.password = '' - this.$v.$reset() - this.$refs.password.focus() + .catch(error => { + // If the status code is 400 the provided email or password is incorrect. + if (error.response && error.response.status === 400) { + this.invalid = true + this.credentials.password = '' + this.$v.$reset() + this.$refs.password.focus() + } }) .then(() => { this.loading = false diff --git a/web-frontend/pages/login/signup.vue b/web-frontend/pages/login/signup.vue index e88d58735..2774114fb 100644 --- a/web-frontend/pages/login/signup.vue +++ b/web-frontend/pages/login/signup.vue @@ -86,6 +86,7 @@ <button :class="{ 'button-loading': loading }" class="button button-large" + :disabled="loading" > Sign up <i class="fas fa-user-plus"></i> diff --git a/web-frontend/plugins/auth.js b/web-frontend/plugins/auth.js index 7ee29cba2..73e70a920 100644 --- a/web-frontend/plugins/auth.js +++ b/web-frontend/plugins/auth.js @@ -1,16 +1,4 @@ -import { client } from '@/services/client' - export default function({ store }) { - // Create a request interceptor to add the authorization token to every - // request if the user is authenticated. - client.interceptors.request.use(config => { - if (store.getters['auth/isAuthenticated']) { - const token = store.getters['auth/token'] - config.headers.Authorization = `JWT: ${token}` - } - return config - }) - // If the user is authenticated, but is not refreshing in the browser means // that the refresh was done on the server side, so we need to manually start // the refreshing timeout here. diff --git a/web-frontend/plugins/client.js b/web-frontend/plugins/client.js new file mode 100644 index 000000000..cdefb595a --- /dev/null +++ b/web-frontend/plugins/client.js @@ -0,0 +1,45 @@ +import { client } from '@/services/client' + +export default function({ store }) { + // Create a request interceptor to add the authorization token to every + // request if the user is authenticated. + client.interceptors.request.use(config => { + if (store.getters['auth/isAuthenticated']) { + const token = store.getters['auth/token'] + config.headers.Authorization = `JWT ${token}` + } + return config + }) + + // Create a response interceptor to add more detail tot the error message + // and to create a notification when there is a network error. + client.interceptors.response.use( + response => { + return response + }, + error => { + error.responseError = undefined + error.responseDetail = undefined + + // Add the error message in the response to the error object. + if ( + error.response && + 'error' in error.response.data && + 'detail' in error.response.data + ) { + error.responseError = error.response.data.error + error.responseDetail = error.response.data.detail + } + + // Network error, the server could not reached + if (!error.response) { + store.dispatch('notification/error', { + title: 'Network error', + message: 'Could not connect to the API server.' + }) + } + + return Promise.reject(error) + } + ) +} diff --git a/web-frontend/plugins/global.js b/web-frontend/plugins/global.js new file mode 100644 index 000000000..b000c73f9 --- /dev/null +++ b/web-frontend/plugins/global.js @@ -0,0 +1,9 @@ +import Vue from 'vue' + +import Context from '@/components/Context' +import Modal from '@/components/Modal' +import Editable from '@/components/Editable' + +Vue.component('Context', Context) +Vue.component('Modal', Modal) +Vue.component('Editable', Editable) diff --git a/web-frontend/services/client.js b/web-frontend/services/client.js index 52b9cc1ff..2315c1f32 100644 --- a/web-frontend/services/client.js +++ b/web-frontend/services/client.js @@ -8,24 +8,3 @@ export const client = axios.create({ 'Content-Type': 'application/json' } }) - -client.interceptors.response.use( - response => { - return response - }, - error => { - error.responseError = undefined - error.responseDetail = undefined - - if ( - error.response && - 'error' in error.response.data && - 'detail' in error.response.data - ) { - error.responseError = error.response.data.error - error.responseDetail = error.response.data.detail - } - - return Promise.reject(error) - } -) diff --git a/web-frontend/services/group.js b/web-frontend/services/group.js new file mode 100644 index 000000000..62e377bc4 --- /dev/null +++ b/web-frontend/services/group.js @@ -0,0 +1,16 @@ +import { client } from './client' + +export default { + fetchAll() { + return client.get('/groups/') + }, + create(values) { + return client.post('/groups/', values) + }, + update(id, values) { + return client.patch(`/groups/${id}/`, values) + }, + delete(id) { + return client.delete(`/groups/${id}/`) + } +} diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js index 86dca60db..ceb43ab44 100644 --- a/web-frontend/store/auth.js +++ b/web-frontend/store/auth.js @@ -49,6 +49,14 @@ export const actions = { } ) }, + /** + * Logs off the user by removing the token as a cookie and clearing the user + * data. + */ + logoff({ commit }) { + unsetToken(this.app.$cookies) + commit('CLEAR_USER_DATA') + }, /** * Refresh the existing token. If successful commit the new token and start a * new refresh timeout. If unsuccessful the existing cookie and user data is @@ -100,10 +108,19 @@ export const getters = { token(state) { return state.token }, + getName(state) { + return state.user ? state.user.first_name : '' + }, + getNameAbbreviation(state) { + return state.user ? state.user.first_name.split('')[0] : '' + }, + getEmail(state) { + return state.user ? state.user.email : '' + }, /** * Returns the amount of seconds it will take before the tokes expires. - * @TODO figure out what happens if the browser and server time and not very - * much in sync. + * @TODO figure out what happens if the browser and server time are not in + * sync. */ tokenExpireSeconds(state) { const now = Math.ceil(new Date().getTime() / 1000) diff --git a/web-frontend/store/group.js b/web-frontend/store/group.js new file mode 100644 index 000000000..4d4ab10a1 --- /dev/null +++ b/web-frontend/store/group.js @@ -0,0 +1,80 @@ +// import { set } from 'vue' + +import GroupService from '@/services/group' + +export const state = () => ({ + loaded: false, + loading: false, + items: [] +}) + +export const mutations = { + SET_LOADED(state, loaded) { + state.loaded = loaded + }, + SET_LOADING(state, loading) { + state.loading = loading + }, + SET_ITEMS(state, items) { + state.items = items + }, + ADD_ITEM(state, item) { + state.items.push(item) + }, + UPDATE_ITEM(state, values) { + const index = state.items.findIndex(item => item.id === values.id) + Object.assign(state.items[index], state.items[index], values) + }, + DELETE_ITEM(state, id) { + const index = state.items.findIndex(item => item.id === id) + state.items.splice(index, 1) + } +} + +export const actions = { + loadAll({ state, dispatch }) { + if (!state.loaded && !state.loading) { + dispatch('fetchAll') + } + }, + fetchAll({ commit }) { + commit('SET_LOADING', true) + + return GroupService.fetchAll() + .then(({ data }) => { + commit('SET_LOADED', true) + commit('SET_ITEMS', data) + }) + .catch(() => { + commit('SET_ITEMS', []) + }) + .then(() => { + commit('SET_LOADING', false) + }) + }, + create({ commit }, values) { + return GroupService.create(values).then(({ data }) => { + commit('ADD_ITEM', data) + }) + }, + update({ commit }, { id, values }) { + return GroupService.update(id, values).then(({ data }) => { + commit('UPDATE_ITEM', data) + }) + }, + delete({ commit }, id) { + return GroupService.delete(id).then(() => { + console.log(id) + commit('DELETE_ITEM', id) + }) + } +} + +export const getters = { + isLoaded(state) { + return state.loaded + }, + isLoading(state) { + return state.loading + } +} diff --git a/web-frontend/store/notification.js b/web-frontend/store/notification.js new file mode 100644 index 000000000..b1746c03f --- /dev/null +++ b/web-frontend/store/notification.js @@ -0,0 +1,43 @@ +import { uuid } from '@/utils/string' + +export const state = () => ({ + items: [] +}) + +export const mutations = { + ADD(state, notification) { + state.items.unshift(notification) + }, + REMOVE(state, notification) { + const index = state.items.indexOf(notification) + state.items.splice(index, 1) + } +} + +export const actions = { + add({ commit }, { type, title, message }) { + commit('ADD', { + id: uuid(), + type: type, + title: title, + message: message + }) + }, + info({ dispatch }, { title, message }) { + dispatch('add', { type: 'info', title, message }) + }, + error({ dispatch }, { title, message }) { + dispatch('add', { type: 'error', title, message }) + }, + warning({ dispatch }, { title, message }) { + dispatch('add', { type: 'warning', title, message }) + }, + success({ dispatch }, { title, message }) { + dispatch('add', { type: 'success', title, message }) + }, + remove({ commit }, notification) { + commit('REMOVE', notification) + } +} + +export const getters = {} diff --git a/web-frontend/store/sidebar.js b/web-frontend/store/sidebar.js new file mode 100644 index 000000000..0f3a33f8a --- /dev/null +++ b/web-frontend/store/sidebar.js @@ -0,0 +1,24 @@ +export const state = () => ({ + collapsed: false +}) + +export const mutations = { + SET_COLLAPSED(state, collapsed) { + state.collapsed = collapsed + } +} + +export const actions = { + toggleCollapsed({ commit, getters }, value) { + if (value === undefined) { + value = !getters.isCollapsed + } + commit('SET_COLLAPSED', value) + } +} + +export const getters = { + isCollapsed(state) { + return !!state.collapsed + } +} diff --git a/web-frontend/test/utils/string.spec.js b/web-frontend/test/utils/string.spec.js new file mode 100644 index 000000000..f24d56267 --- /dev/null +++ b/web-frontend/test/utils/string.spec.js @@ -0,0 +1,8 @@ +import { uuid } from '@/utils/string' + +describe('test string utils', () => { + test('test uuid', () => { + const value = uuid() + expect(typeof value).toBe('string') + }) +}) diff --git a/web-frontend/utils/dom.js b/web-frontend/utils/dom.js new file mode 100644 index 000000000..e93334b4f --- /dev/null +++ b/web-frontend/utils/dom.js @@ -0,0 +1,35 @@ +/** + * Checks if the target is the same as the provided element of that the element + * contains the target. Returns true is this is the case. + * + * @returns boolean + */ +export const isElement = (element, target) => { + return element === target || element.contains(target) +} + +/** + * This function will focus a contenteditable and place the cursor at the end. + * + * @param element + */ +export const focusEnd = element => { + element.focus() + + if ( + typeof window.getSelection !== 'undefined' && + typeof document.createRange !== 'undefined' + ) { + const range = document.createRange() + range.selectNodeContents(element) + range.collapse(false) + const sel = window.getSelection() + sel.removeAllRanges() + sel.addRange(range) + } else if (typeof document.body.createTextRange !== 'undefined') { + const textRange = document.body.createTextRange() + textRange.moveToElementText(element) + textRange.collapse(false) + textRange.select() + } +} diff --git a/web-frontend/utils/string.js b/web-frontend/utils/string.js new file mode 100644 index 000000000..14b0ffc06 --- /dev/null +++ b/web-frontend/utils/string.js @@ -0,0 +1,9 @@ +export const uuid = function() { + let dt = new Date().getTime() + const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (dt + Math.random() * 16) % 16 | 0 + dt = Math.floor(dt / 16) + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) + }) + return uuid +} diff --git a/web-frontend/yarn.lock b/web-frontend/yarn.lock index 6dbbac02d..883eba71c 100644 --- a/web-frontend/yarn.lock +++ b/web-frontend/yarn.lock @@ -6035,6 +6035,11 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= +jsdom-global@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/jsdom-global/-/jsdom-global-3.0.2.tgz#6bd299c13b0c4626b2da2c0393cd4385d606acb9" + integrity sha1-a9KZwTsMRiay2iwDk81DhdYGrLk= + jsdom@^11.5.1: version "11.12.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8"