mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-10 07:37:30 +00:00
Merge branch '7-base-application' into 'develop'
Resolve "Create base application template" Closes #7 See merge request bramw/baserow!3
This commit is contained in:
commit
8fcfbd957b
72 changed files with 2034 additions and 83 deletions
backend
requirements
src/baserow
api/v0
config
core
tests
web-frontend
assets/scss
components
config
intellij-idea.webpack.config.jslayouts
mixins
package.jsonpages
plugins
services
store
test/utils
utils
yarn.lock
|
@ -3,3 +3,4 @@ django-cors-headers==3.0.2
|
||||||
djangorestframework==3.9.4
|
djangorestframework==3.9.4
|
||||||
djangorestframework-jwt==1.11.0
|
djangorestframework-jwt==1.11.0
|
||||||
psycopg2==2.8.3
|
psycopg2==2.8.3
|
||||||
|
ipython==7.7.0
|
||||||
|
|
29
backend/src/baserow/api/v0/groups/serializers.py
Normal file
29
backend/src/baserow/api/v0/groups/serializers.py
Normal file
|
@ -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())
|
12
backend/src/baserow/api/v0/groups/urls.py
Normal file
12
backend/src/baserow/api/v0/groups/urls.py
Normal file
|
@ -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')
|
||||||
|
]
|
73
backend/src/baserow/api/v0/groups/views.py
Normal file
73
backend/src/baserow/api/v0/groups/views.py
Normal file
|
@ -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)
|
|
@ -1,10 +1,12 @@
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
|
||||||
from .user import urls as user_urls
|
from .user import urls as user_urls
|
||||||
|
from .groups import urls as group_urls
|
||||||
|
|
||||||
|
|
||||||
app_name = 'baserow.api.v0'
|
app_name = 'baserow.api.v0'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('user/', include(user_urls, namespace='user'))
|
path('user/', include(user_urls, namespace='user')),
|
||||||
|
path('groups/', include(group_urls, namespace='groups'))
|
||||||
]
|
]
|
||||||
|
|
|
@ -25,6 +25,7 @@ INSTALLED_APPS = [
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
|
|
||||||
|
'baserow.core',
|
||||||
'baserow.api.v0'
|
'baserow.api.v0'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -3,5 +3,5 @@ from django.conf.urls import url
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api')),
|
url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api_v0')),
|
||||||
]
|
]
|
||||||
|
|
1
backend/src/baserow/core/__init__.py
Normal file
1
backend/src/baserow/core/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
app_name = 'baserow.group'
|
5
backend/src/baserow/core/apps.py
Normal file
5
backend/src/baserow/core/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
name = 'baserow.core'
|
2
backend/src/baserow/core/exceptions.py
Normal file
2
backend/src/baserow/core/exceptions.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class UserNotIngroupError(Exception):
|
||||||
|
pass
|
79
backend/src/baserow/core/handler.py
Normal file
79
backend/src/baserow/core/handler.py
Normal file
|
@ -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)
|
8
backend/src/baserow/core/managers.py
Normal file
8
backend/src/baserow/core/managers.py
Normal file
|
@ -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')
|
48
backend/src/baserow/core/migrations/0001_initial.py
Normal file
48
backend/src/baserow/core/migrations/0001_initial.py
Normal file
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
41
backend/src/baserow/core/models.py
Normal file
41
backend/src/baserow/core/models.py
Normal file
|
@ -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
|
121
backend/tests/baserow/api/v0/group/test_group_views.py
Normal file
121
backend/tests/baserow/api/v0/group/test_group_views.py
Normal file
|
@ -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]
|
|
@ -16,32 +16,32 @@ User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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',
|
data_fixture.create_user(email='test@test.nl', password='password',
|
||||||
first_name='Test1')
|
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',
|
'username': 'no_existing@test.nl',
|
||||||
'password': 'password'
|
'password': 'password'
|
||||||
})
|
}, format='json')
|
||||||
json = response.json()
|
json = response.json()
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert len(json['non_field_errors']) > 0
|
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',
|
'username': 'test@test.nl',
|
||||||
'password': 'wrong_password'
|
'password': 'wrong_password'
|
||||||
})
|
}, format='json')
|
||||||
json = response.json()
|
json = response.json()
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert len(json['non_field_errors']) > 0
|
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',
|
'username': 'test@test.nl',
|
||||||
'password': 'password'
|
'password': 'password'
|
||||||
})
|
}, format='json')
|
||||||
json = response.json()
|
json = response.json()
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
@ -52,17 +52,16 @@ def test_token_auth(client, data_fixture):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_token_refresh(client, data_fixture):
|
def test_token_refresh(api_client, data_fixture):
|
||||||
user = data_fixture.create_user(email='test@test.nl', password='password',
|
user, token = data_fixture.create_user_and_token(
|
||||||
first_name='Test1')
|
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
|
assert response.status_code == 400
|
||||||
|
|
||||||
payload = jwt_payload_handler(user)
|
response = api_client.post(reverse('api_v0:user:token_refresh'),
|
||||||
token = jwt_encode_handler(payload)
|
{'token': token}, format='json')
|
||||||
|
|
||||||
response = client.post(reverse('api:user:token_refresh'), {'token': token})
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'token' in response.json()
|
assert 'token' in response.json()
|
||||||
|
|
||||||
|
@ -71,5 +70,6 @@ def test_token_refresh(client, data_fixture):
|
||||||
payload = jwt_payload_handler(user)
|
payload = jwt_payload_handler(user)
|
||||||
token = jwt_encode_handler(payload)
|
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
|
assert response.status_code == 400
|
||||||
|
|
|
@ -9,11 +9,11 @@ User = get_user_model()
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_create_user(client):
|
def test_create_user(client):
|
||||||
response = client.post(reverse('api:user:index'), {
|
response = client.post(reverse('api_v0:user:index'), {
|
||||||
'name': 'Test1',
|
'name': 'Test1',
|
||||||
'email': 'test@test.nl',
|
'email': 'test@test.nl',
|
||||||
'password': 'test12'
|
'password': 'test12'
|
||||||
})
|
}, format='json')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user = User.objects.get(email='test@test.nl')
|
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.email == 'test@test.nl'
|
||||||
assert user.password != ''
|
assert user.password != ''
|
||||||
|
|
||||||
response_failed = client.post(reverse('api:user:index'), {
|
response_failed = client.post(reverse('api_v0:user:index'), {
|
||||||
'name': 'Test1',
|
'name': 'Test1',
|
||||||
'email': 'test@test.nl',
|
'email': 'test@test.nl',
|
||||||
'password': 'test12'
|
'password': 'test12'
|
||||||
})
|
}, format='json')
|
||||||
|
|
||||||
assert response_failed.status_code == 400
|
assert response_failed.status_code == 400
|
||||||
assert response_failed.json()['error'] == 'ERROR_ALREADY_EXISTS'
|
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'
|
'email': 'test'
|
||||||
})
|
}, format='json')
|
||||||
|
|
||||||
assert response_failed_2.status_code == 400
|
assert response_failed_2.status_code == 400
|
||||||
|
|
94
backend/tests/baserow/core/test_core_handler.py
Normal file
94
backend/tests/baserow/core/test_core_handler.py
Normal file
|
@ -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]
|
24
backend/tests/baserow/core/test_core_managers.py
Normal file
24
backend/tests/baserow/core/test_core_managers.py
Normal file
|
@ -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
|
26
backend/tests/baserow/core/test_core_models.py
Normal file
26
backend/tests/baserow/core/test_core_models.py
Normal file
|
@ -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)
|
|
@ -5,3 +5,9 @@ import pytest
|
||||||
def data_fixture():
|
def data_fixture():
|
||||||
from .fixtures import Fixtures
|
from .fixtures import Fixtures
|
||||||
return Fixtures()
|
return Fixtures()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api_client():
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
return APIClient()
|
||||||
|
|
7
backend/tests/fixtures/__init__.py
vendored
7
backend/tests/fixtures/__init__.py
vendored
|
@ -1,5 +1,8 @@
|
||||||
|
from faker import Faker
|
||||||
|
|
||||||
from .user import UserFixtures
|
from .user import UserFixtures
|
||||||
|
from .group import GroupFixtures
|
||||||
|
|
||||||
|
|
||||||
class Fixtures(UserFixtures):
|
class Fixtures(UserFixtures, GroupFixtures):
|
||||||
pass
|
fake = Faker()
|
||||||
|
|
32
backend/tests/fixtures/group.py
vendored
Normal file
32
backend/tests/fixtures/group.py
vendored
Normal file
|
@ -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)
|
31
backend/tests/fixtures/user.py
vendored
31
backend/tests/fixtures/user.py
vendored
|
@ -1,20 +1,39 @@
|
||||||
from django.contrib.auth import get_user_model
|
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()
|
User = get_user_model()
|
||||||
|
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
|
||||||
|
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
|
||||||
|
|
||||||
|
|
||||||
class UserFixtures:
|
class UserFixtures:
|
||||||
|
def generate_token(self, user):
|
||||||
|
payload = jwt_payload_handler(user)
|
||||||
|
token = jwt_encode_handler(payload)
|
||||||
|
return token
|
||||||
|
|
||||||
def create_user(self, **kwargs):
|
def create_user(self, **kwargs):
|
||||||
kwargs.setdefault('email', fake.email())
|
if 'email' not in kwargs:
|
||||||
kwargs.setdefault('username', kwargs['email'])
|
kwargs['email'] = self.fake.email()
|
||||||
kwargs.setdefault('first_name', fake.name())
|
|
||||||
kwargs.setdefault('password', 'password')
|
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 = User(**kwargs)
|
||||||
user.set_password(kwargs['password'])
|
user.set_password(kwargs['password'])
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
def create_user_and_token(self, **kwargs):
|
||||||
|
user = self.create_user(**kwargs)
|
||||||
|
token = self.generate_token(user)
|
||||||
|
return user, token
|
||||||
|
|
36
start_osx.sh
Executable file
36
start_osx.sh
Executable file
|
@ -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"
|
3
stop_osx.sh
Executable file
3
stop_osx.sh
Executable file
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
docker-compose kill
|
|
@ -39,6 +39,14 @@
|
||||||
.alert-content {
|
.alert-content {
|
||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
color: $title;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin fixed-height($height, $font-size) {
|
@mixin fixed-height($height, $font-size) {
|
||||||
|
@ -54,6 +62,18 @@
|
||||||
text-align: center;
|
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
|
// 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.
|
// resulting position: absolute; with corresponding top, right, bottom, left values.
|
||||||
// -------
|
// -------
|
||||||
|
|
|
@ -63,4 +63,15 @@ $z-index-layout-col-2: 2;
|
||||||
$z-index-layout-col-3: 1;
|
$z-index-layout-col-3: 1;
|
||||||
$z-index-layout-col-3-1: 5;
|
$z-index-layout-col-3-1: 5;
|
||||||
$z-index-layout-col-3-2: 4;
|
$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;
|
||||||
|
|
|
@ -11,6 +11,10 @@ body {
|
||||||
background-color: $color-neutral-100;
|
background-color: $color-neutral-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
|
|
|
@ -8,6 +8,10 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visibility-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.align-right {
|
.align-right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,10 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 72px;
|
padding-left: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.alert-with-shadow {
|
||||||
|
box-shadow: 0 2px 6px 0 rgba($black, 0.16);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-icon {
|
.alert-icon {
|
||||||
|
@ -35,6 +39,10 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
@include absolute(10px, 10px, auto, auto);
|
||||||
|
}
|
||||||
|
|
||||||
.alert-success {
|
.alert-success {
|
||||||
@include alert-style($color-success-100, $color-success-300, $color-success-900, $color-success-700, $color-success-400);
|
@include alert-style($color-success-100, $color-success-300, $color-success-900, $color-success-700, $color-success-400);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
.context {
|
.context {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: $z-index-context;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background-color: $white;
|
background-color: $white;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
@ -8,6 +8,17 @@
|
||||||
box-shadow: 0 2px 6px 0 rgba($black, 0.16);
|
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 {
|
.context-menu-title {
|
||||||
color: $color-neutral-600;
|
color: $color-neutral-600;
|
||||||
padding: 12px 8px 2px 8px;
|
padding: 12px 8px 2px 8px;
|
||||||
|
|
|
@ -57,6 +57,10 @@
|
||||||
> div {
|
> div {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.actions-right {
|
||||||
|
justify-content: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-links {
|
.action-links {
|
||||||
|
|
3
web-frontend/assets/scss/components/_loading.scss
Normal file
3
web-frontend/assets/scss/components/_loading.scss
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.loading {
|
||||||
|
@include loading();
|
||||||
|
}
|
7
web-frontend/assets/scss/components/_notifications.scss
Normal file
7
web-frontend/assets/scss/components/_notifications.scss
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.notifications {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 30px;
|
||||||
|
width: 320px;
|
||||||
|
z-index: $z-index-notifications;
|
||||||
|
}
|
|
@ -82,6 +82,17 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.select-item-loading {
|
||||||
|
background-color: $color-neutral-100;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: " ";
|
||||||
|
|
||||||
|
@include loading(14px);
|
||||||
|
@include absolute(9px, 9px, auto, auto);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-item-link {
|
.select-item-link {
|
||||||
|
@ -114,7 +125,7 @@
|
||||||
color: $color-neutral-700;
|
color: $color-neutral-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
:hover > & {
|
:not(.select-item-loading):hover > & {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,3 +26,5 @@
|
||||||
@import 'components/views/grid/boolean';
|
@import 'components/views/grid/boolean';
|
||||||
@import 'components/views/grid/number';
|
@import 'components/views/grid/number';
|
||||||
@import 'components/box_page';
|
@import 'components/box_page';
|
||||||
|
@import 'components/loading';
|
||||||
|
@import 'components/notifications';
|
||||||
|
|
184
web-frontend/components/Context.vue
Normal file
184
web-frontend/components/Context.vue
Normal file
|
@ -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>
|
103
web-frontend/components/Editable.vue
Normal file
103
web-frontend/components/Editable.vue
Normal file
|
@ -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>
|
81
web-frontend/components/Modal.vue
Normal file
81
web-frontend/components/Modal.vue
Normal file
|
@ -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>
|
49
web-frontend/components/group/CreateGroupModal.vue
Normal file
49
web-frontend/components/group/CreateGroupModal.vue
Normal file
|
@ -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>
|
47
web-frontend/components/group/GroupForm.vue
Normal file
47
web-frontend/components/group/GroupForm.vue
Normal file
|
@ -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>
|
154
web-frontend/components/group/GroupsContext.vue
Normal file
154
web-frontend/components/group/GroupsContext.vue
Normal file
|
@ -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>
|
42
web-frontend/components/notifications/Notification.vue
Normal file
42
web-frontend/components/notifications/Notification.vue
Normal file
|
@ -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>
|
25
web-frontend/components/notifications/Notifications.vue
Normal file
25
web-frontend/components/notifications/Notifications.vue
Normal file
|
@ -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>
|
|
@ -25,7 +25,12 @@ export default {
|
||||||
/*
|
/*
|
||||||
** Plugins to load before mounting the App
|
** 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
|
** Nuxt.js modules
|
||||||
|
|
16
web-frontend/intellij-idea.webpack.config.js
Normal file
16
web-frontend/intellij-idea.webpack.config.js
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
web-frontend/layouts/app.vue
Normal file
106
web-frontend/layouts/app.vue
Normal file
|
@ -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>
|
|
@ -1,5 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<nuxt />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<Notifications></Notifications>
|
||||||
<div class="box-page-header"></div>
|
<div class="box-page-header"></div>
|
||||||
<div class="box-page">
|
<div class="box-page">
|
||||||
<div class="box login-page login-page-login">
|
<div class="box login-page login-page-login">
|
||||||
|
@ -8,3 +9,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Notifications from '@/components/notifications/Notifications'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Notifications }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
25
web-frontend/mixins/context.js
Normal file
25
web-frontend/mixins/context.js
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
web-frontend/mixins/form.js
Normal file
34
web-frontend/mixins/form.js
Normal file
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
web-frontend/mixins/modal.js
Normal file
25
web-frontend/mixins/modal.js
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
web-frontend/mixins/moveToBody.js
Normal file
32
web-frontend/mixins/moveToBody.js
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -46,6 +46,7 @@
|
||||||
"eslint-plugin-vue": "^5.2.2",
|
"eslint-plugin-vue": "^5.2.2",
|
||||||
"jest": "^24.1.0",
|
"jest": "^24.1.0",
|
||||||
"jsdom": "^15.1.1",
|
"jsdom": "^15.1.1",
|
||||||
|
"jsdom-global": "^3.0.2",
|
||||||
"moxios": "^0.4.0",
|
"moxios": "^0.4.0",
|
||||||
"node-mocks-http": "^1.7.6",
|
"node-mocks-http": "^1.7.6",
|
||||||
"nodemon": "^1.18.9",
|
"nodemon": "^1.18.9",
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>Welcome {{ user }}</h1>
|
<h1>Welcome {{ user }}</h1>
|
||||||
|
<p>
|
||||||
|
{{ groups }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -8,10 +11,11 @@
|
||||||
import { mapState } from 'vuex'
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
middleware: 'authenticated',
|
layout: 'app',
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
user: state => state.auth.user
|
user: state => state.auth.user,
|
||||||
|
groups: state => state.group.items
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,7 @@
|
||||||
<button
|
<button
|
||||||
:class="{ 'button-loading': loading }"
|
:class="{ 'button-loading': loading }"
|
||||||
class="button button-large"
|
class="button button-large"
|
||||||
|
:disabled="loading"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
<i class="fas fa-lock-open"></i>
|
<i class="fas fa-lock-open"></i>
|
||||||
|
@ -104,11 +105,14 @@ export default {
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$nuxt.$router.replace({ name: 'app' })
|
this.$nuxt.$router.replace({ name: 'app' })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(error => {
|
||||||
this.invalid = true
|
// If the status code is 400 the provided email or password is incorrect.
|
||||||
this.credentials.password = ''
|
if (error.response && error.response.status === 400) {
|
||||||
this.$v.$reset()
|
this.invalid = true
|
||||||
this.$refs.password.focus()
|
this.credentials.password = ''
|
||||||
|
this.$v.$reset()
|
||||||
|
this.$refs.password.focus()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|
|
@ -86,6 +86,7 @@
|
||||||
<button
|
<button
|
||||||
:class="{ 'button-loading': loading }"
|
:class="{ 'button-loading': loading }"
|
||||||
class="button button-large"
|
class="button button-large"
|
||||||
|
:disabled="loading"
|
||||||
>
|
>
|
||||||
Sign up
|
Sign up
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
|
|
|
@ -1,16 +1,4 @@
|
||||||
import { client } from '@/services/client'
|
|
||||||
|
|
||||||
export default function({ store }) {
|
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
|
// 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
|
// that the refresh was done on the server side, so we need to manually start
|
||||||
// the refreshing timeout here.
|
// the refreshing timeout here.
|
||||||
|
|
45
web-frontend/plugins/client.js
Normal file
45
web-frontend/plugins/client.js
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
9
web-frontend/plugins/global.js
Normal file
9
web-frontend/plugins/global.js
Normal file
|
@ -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)
|
|
@ -8,24 +8,3 @@ export const client = axios.create({
|
||||||
'Content-Type': 'application/json'
|
'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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
16
web-frontend/services/group.js
Normal file
16
web-frontend/services/group.js
Normal file
|
@ -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}/`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
* 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
|
* new refresh timeout. If unsuccessful the existing cookie and user data is
|
||||||
|
@ -100,10 +108,19 @@ export const getters = {
|
||||||
token(state) {
|
token(state) {
|
||||||
return state.token
|
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.
|
* 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
|
* @TODO figure out what happens if the browser and server time are not in
|
||||||
* much in sync.
|
* sync.
|
||||||
*/
|
*/
|
||||||
tokenExpireSeconds(state) {
|
tokenExpireSeconds(state) {
|
||||||
const now = Math.ceil(new Date().getTime() / 1000)
|
const now = Math.ceil(new Date().getTime() / 1000)
|
||||||
|
|
80
web-frontend/store/group.js
Normal file
80
web-frontend/store/group.js
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
43
web-frontend/store/notification.js
Normal file
43
web-frontend/store/notification.js
Normal file
|
@ -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 = {}
|
24
web-frontend/store/sidebar.js
Normal file
24
web-frontend/store/sidebar.js
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
8
web-frontend/test/utils/string.spec.js
Normal file
8
web-frontend/test/utils/string.spec.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { uuid } from '@/utils/string'
|
||||||
|
|
||||||
|
describe('test string utils', () => {
|
||||||
|
test('test uuid', () => {
|
||||||
|
const value = uuid()
|
||||||
|
expect(typeof value).toBe('string')
|
||||||
|
})
|
||||||
|
})
|
35
web-frontend/utils/dom.js
Normal file
35
web-frontend/utils/dom.js
Normal file
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
9
web-frontend/utils/string.js
Normal file
9
web-frontend/utils/string.js
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -6035,6 +6035,11 @@ jsbn@~0.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
||||||
integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
|
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:
|
jsdom@^11.5.1:
|
||||||
version "11.12.0"
|
version "11.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8"
|
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8"
|
||||||
|
|
Loading…
Add table
Reference in a new issue