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

Merge branch '7-base-application' into 'develop'

Resolve "Create base application template"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2019-08-26 17:41:45 +00:00
commit fb8333c72a
72 changed files with 2034 additions and 83 deletions

View file

@ -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

View 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())

View 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')
]

View 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)

View file

@ -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'))
]

View file

@ -25,6 +25,7 @@ INSTALLED_APPS = [
'rest_framework',
'corsheaders',
'baserow.core',
'baserow.api.v0'
]

View file

@ -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')),
]

View file

@ -0,0 +1 @@
app_name = 'baserow.group'

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
name = 'baserow.core'

View file

@ -0,0 +1,2 @@
class UserNotIngroupError(Exception):
pass

View 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)

View 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')

View 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),
),
]

View 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

View 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]

View file

@ -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

View file

@ -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

View 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]

View 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

View 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)

View file

@ -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()

View file

@ -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()

32
backend/tests/fixtures/group.py vendored Normal file
View 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)

View file

@ -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

36
start_osx.sh Executable file
View 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
View file

@ -0,0 +1,3 @@
#!/bin/bash
docker-compose kill

View file

@ -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.
// -------

View file

@ -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;

View file

@ -11,6 +11,10 @@ body {
background-color: $color-neutral-100;
}
a {
cursor: pointer;
}
*,
*::before,
*::after {

View file

@ -8,6 +8,10 @@
display: none;
}
.visibility-hidden {
visibility: hidden;
}
.align-right {
text-align: right;
}

View file

@ -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);
}

View file

@ -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;

View file

@ -57,6 +57,10 @@
> div {
width: 100%;
}
&.actions-right {
justify-content: right;
}
}
.action-links {

View file

@ -0,0 +1,3 @@
.loading {
@include loading();
}

View file

@ -0,0 +1,7 @@
.notifications {
position: fixed;
top: 0;
right: 30px;
width: 320px;
z-index: $z-index-notifications;
}

View file

@ -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;
}
}

View file

@ -26,3 +26,5 @@
@import 'components/views/grid/boolean';
@import 'components/views/grid/number';
@import 'components/box_page';
@import 'components/loading';
@import 'components/notifications';

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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

View 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)
}
}
}

View 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>

View file

@ -1,5 +0,0 @@
<template>
<div>
<nuxt />
</div>
</template>

View file

@ -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>

View 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)
}
}
}

View 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()
}
}
}

View 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)
}
}
}

View 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)
}
}
}

View file

@ -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",

View file

@ -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
})
}
}

View file

@ -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

View file

@ -86,6 +86,7 @@
<button
:class="{ 'button-loading': loading }"
class="button button-large"
:disabled="loading"
>
Sign up
<i class="fas fa-user-plus"></i>

View file

@ -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.

View 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)
}
)
}

View 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)

View file

@ -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)
}
)

View 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}/`)
}
}

View file

@ -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)

View 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
}
}

View 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 = {}

View 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
}
}

View 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
View 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()
}
}

View 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
}

View file

@ -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"