From 38b2a697dc5c582b26c7b9bf9e2df24976e96703 Mon Sep 17 00:00:00 2001
From: Bram Wiepjes <bramw@protonmail.com>
Date: Mon, 29 Jul 2019 22:00:00 +0200
Subject: [PATCH] created groups listing endpoint, list groups in context menu
 and made context and modals stackable

---
 .../src/baserow/api/v0/groups/serializers.py  |  20 ++
 backend/src/baserow/api/v0/groups/urls.py     |  10 +
 backend/src/baserow/api/v0/groups/views.py    |  19 ++
 backend/src/baserow/api/v0/urls.py            |   4 +-
 backend/src/baserow/config/settings/base.py   |   1 +
 backend/src/baserow/config/urls.py            |   2 +-
 backend/src/baserow/core/__init__.py          |   1 +
 backend/src/baserow/core/apps.py              |   5 +
 backend/src/baserow/core/managers.py          |   8 +
 .../baserow/core/migrations/0001_initial.py   |  48 +++++
 .../src/baserow/core/migrations/__init__.py   |   0
 backend/src/baserow/core/models.py            |  32 +++
 .../baserow/api/v0/group/test_group_views.py  |  31 +++
 .../baserow/api/v0/user/test_token_auth.py    |  12 +-
 .../baserow/api/v0/user/test_user_views.py    |   6 +-
 .../tests/baserow/core/test_core_managers.py  |  24 +++
 .../tests/baserow/core/test_core_models.py    |  13 ++
 backend/tests/fixtures/__init__.py            |   7 +-
 backend/tests/fixtures/group.py               |  24 +++
 backend/tests/fixtures/user.py                |   6 +-
 .../assets/scss/abstracts/_variables.scss     |   9 +-
 .../assets/scss/components/_context.scss      |  11 +
 .../assets/scss/components/_loading.scss      |  11 +
 web-frontend/assets/scss/default.scss         |   1 +
 web-frontend/components/Context.vue           |  53 +++--
 web-frontend/components/Modal.vue             |  66 ++++++
 .../components/group/CreateGroupModal.vue     |  27 +++
 .../components/group/GroupsContext.vue        |  89 ++++++++
 web-frontend/config/nuxt.config.base.js       |   6 +-
 web-frontend/intellij-idea.webpack.config.js  |  16 ++
 web-frontend/layouts/app.vue                  | 193 +-----------------
 web-frontend/mixins/moveToBody.js             |  32 +++
 web-frontend/plugins/auth.js                  |   2 +-
 web-frontend/plugins/global.js                |   7 +
 web-frontend/services/group.js                |   7 +
 web-frontend/store/group.js                   |  51 +++++
 36 files changed, 616 insertions(+), 238 deletions(-)
 create mode 100644 backend/src/baserow/api/v0/groups/serializers.py
 create mode 100644 backend/src/baserow/api/v0/groups/urls.py
 create mode 100644 backend/src/baserow/api/v0/groups/views.py
 create mode 100644 backend/src/baserow/core/__init__.py
 create mode 100644 backend/src/baserow/core/apps.py
 create mode 100644 backend/src/baserow/core/managers.py
 create mode 100644 backend/src/baserow/core/migrations/0001_initial.py
 create mode 100644 backend/src/baserow/core/migrations/__init__.py
 create mode 100644 backend/src/baserow/core/models.py
 create mode 100644 backend/tests/baserow/api/v0/group/test_group_views.py
 create mode 100644 backend/tests/baserow/core/test_core_managers.py
 create mode 100644 backend/tests/baserow/core/test_core_models.py
 create mode 100644 backend/tests/fixtures/group.py
 create mode 100644 web-frontend/assets/scss/components/_loading.scss
 create mode 100644 web-frontend/components/Modal.vue
 create mode 100644 web-frontend/components/group/CreateGroupModal.vue
 create mode 100644 web-frontend/components/group/GroupsContext.vue
 create mode 100644 web-frontend/intellij-idea.webpack.config.js
 create mode 100644 web-frontend/mixins/moveToBody.js
 create mode 100644 web-frontend/plugins/global.js
 create mode 100644 web-frontend/services/group.js
 create mode 100644 web-frontend/store/group.js

diff --git a/backend/src/baserow/api/v0/groups/serializers.py b/backend/src/baserow/api/v0/groups/serializers.py
new file mode 100644
index 000000000..83d1d5805
--- /dev/null
+++ b/backend/src/baserow/api/v0/groups/serializers.py
@@ -0,0 +1,20 @@
+from rest_framework import serializers
+
+from baserow.core.models import Group, GroupUser
+
+
+class GroupSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Group
+        fields = ('id', 'name',)
+
+
+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
diff --git a/backend/src/baserow/api/v0/groups/urls.py b/backend/src/baserow/api/v0/groups/urls.py
new file mode 100644
index 000000000..a8e253ae7
--- /dev/null
+++ b/backend/src/baserow/api/v0/groups/urls.py
@@ -0,0 +1,10 @@
+from django.conf.urls import url
+
+from .views import GroupsView
+
+
+app_name = 'baserow.api.v0.group'
+
+urlpatterns = [
+    url(r'^$', GroupsView.as_view(), name='list')
+]
diff --git a/backend/src/baserow/api/v0/groups/views.py b/backend/src/baserow/api/v0/groups/views.py
new file mode 100644
index 000000000..8e0ae5bb8
--- /dev/null
+++ b/backend/src/baserow/api/v0/groups/views.py
@@ -0,0 +1,19 @@
+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 .serializers import GroupUserSerializer
+
+
+class GroupsView(APIView):
+    permission_classes = (IsAuthenticated,)
+
+    @transaction.atomic
+    def get(self, request):
+        groups = GroupUser.objects.filter(user=request.user).select_related('group')
+        serializer = GroupUserSerializer(groups, many=True)
+        return Response(serializer.data)
diff --git a/backend/src/baserow/api/v0/urls.py b/backend/src/baserow/api/v0/urls.py
index 6f5a4a39d..e434d41b2 100644
--- a/backend/src/baserow/api/v0/urls.py
+++ b/backend/src/baserow/api/v0/urls.py
@@ -1,10 +1,12 @@
 from django.urls import path, include
 
 from .user import urls as user_urls
+from .groups import urls as group_urls
 
 
 app_name = 'baserow.api.v0'
 
 urlpatterns = [
-    path('user/', include(user_urls, namespace='user'))
+    path('user/', include(user_urls, namespace='user')),
+    path('groups/', include(group_urls, namespace='groups'))
 ]
diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index 5a0e2b881..aae81bca5 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -25,6 +25,7 @@ INSTALLED_APPS = [
     'rest_framework',
     'corsheaders',
 
+    'baserow.core',
     'baserow.api.v0'
 ]
 
diff --git a/backend/src/baserow/config/urls.py b/backend/src/baserow/config/urls.py
index 4d6cc869c..6de44ae02 100644
--- a/backend/src/baserow/config/urls.py
+++ b/backend/src/baserow/config/urls.py
@@ -3,5 +3,5 @@ from django.conf.urls import url
 
 
 urlpatterns = [
-    url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api')),
+    url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api_v0')),
 ]
diff --git a/backend/src/baserow/core/__init__.py b/backend/src/baserow/core/__init__.py
new file mode 100644
index 000000000..1a371c919
--- /dev/null
+++ b/backend/src/baserow/core/__init__.py
@@ -0,0 +1 @@
+app_name = 'baserow.group'
diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py
new file mode 100644
index 000000000..b2ef0f46f
--- /dev/null
+++ b/backend/src/baserow/core/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ApiConfig(AppConfig):
+    name = 'baserow.core'
diff --git a/backend/src/baserow/core/managers.py b/backend/src/baserow/core/managers.py
new file mode 100644
index 000000000..edde479fe
--- /dev/null
+++ b/backend/src/baserow/core/managers.py
@@ -0,0 +1,8 @@
+from django.db import models
+
+
+class GroupQuerySet(models.QuerySet):
+    def of_user(self, user):
+        return self.filter(
+            users__exact=user
+        ).order_by('groupuser__order')
diff --git a/backend/src/baserow/core/migrations/0001_initial.py b/backend/src/baserow/core/migrations/0001_initial.py
new file mode 100644
index 000000000..6bb6214dd
--- /dev/null
+++ b/backend/src/baserow/core/migrations/0001_initial.py
@@ -0,0 +1,48 @@
+# Generated by Django 2.2.2 on 2019-07-28 13:08
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Group',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True,
+                                        serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=100)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='GroupUser',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True,
+                                        serialize=False, verbose_name='ID')),
+                ('order', models.PositiveIntegerField()),
+                ('group', models.ForeignKey(
+                    on_delete=django.db.models.deletion.CASCADE, to='core.Group')),
+                ('user', models.ForeignKey(
+                    on_delete=django.db.models.deletion.CASCADE,
+                    to=settings.AUTH_USER_MODEL
+                )),
+            ],
+            options={
+                'ordering': ('order',),
+            },
+        ),
+        migrations.AddField(
+            model_name='group',
+            name='users',
+            field=models.ManyToManyField(through='core.GroupUser',
+                                         to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/backend/src/baserow/core/migrations/__init__.py b/backend/src/baserow/core/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/src/baserow/core/models.py b/backend/src/baserow/core/models.py
new file mode 100644
index 000000000..fea912cf2
--- /dev/null
+++ b/backend/src/baserow/core/models.py
@@ -0,0 +1,32 @@
+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()
+
+
+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."""
+        return cls.objects.filter(
+            user=user
+        ).aggregate(
+            models.Max('order')
+        ).get('order__max', 0) + 1
diff --git a/backend/tests/baserow/api/v0/group/test_group_views.py b/backend/tests/baserow/api/v0/group/test_group_views.py
new file mode 100644
index 000000000..0b3c8629b
--- /dev/null
+++ b/backend/tests/baserow/api/v0/group/test_group_views.py
@@ -0,0 +1,31 @@
+import pytest
+
+from django.shortcuts import reverse
+
+from rest_framework_jwt.settings import api_settings
+
+
+jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
+jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
+
+
+@pytest.mark.django_db
+def test_list_groups(client, data_fixture, django_assert_num_queries):
+    user = data_fixture.create_user(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)
+
+    payload = jwt_payload_handler(user)
+    token = jwt_encode_handler(payload)
+
+    with django_assert_num_queries(4):
+        response = 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
diff --git a/backend/tests/baserow/api/v0/user/test_token_auth.py b/backend/tests/baserow/api/v0/user/test_token_auth.py
index 740ef960d..61d4e336e 100644
--- a/backend/tests/baserow/api/v0/user/test_token_auth.py
+++ b/backend/tests/baserow/api/v0/user/test_token_auth.py
@@ -20,7 +20,7 @@ def test_token_auth(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 = client.post(reverse('api_v0:user:token_auth'), {
         'username': 'no_existing@test.nl',
         'password': 'password'
     })
@@ -29,7 +29,7 @@ def test_token_auth(client, data_fixture):
     assert response.status_code == 400
     assert len(json['non_field_errors']) > 0
 
-    response = client.post(reverse('api:user:token_auth'), {
+    response = client.post(reverse('api_v0:user:token_auth'), {
         'username': 'test@test.nl',
         'password': 'wrong_password'
     })
@@ -38,7 +38,7 @@ def test_token_auth(client, data_fixture):
     assert response.status_code == 400
     assert len(json['non_field_errors']) > 0
 
-    response = client.post(reverse('api:user:token_auth'), {
+    response = client.post(reverse('api_v0:user:token_auth'), {
         'username': 'test@test.nl',
         'password': 'password'
     })
@@ -56,13 +56,13 @@ def test_token_refresh(client, data_fixture):
     user = data_fixture.create_user(email='test@test.nl', password='password',
                                     first_name='Test1')
 
-    response = client.post(reverse('api:user:token_refresh'), {'token': 'WRONG_TOKEN'})
+    response = client.post(reverse('api_v0:user:token_refresh'), {'token': 'WRONG_TOKEN'})
     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 = client.post(reverse('api_v0:user:token_refresh'), {'token': token})
     assert response.status_code == 200
     assert 'token' in response.json()
 
@@ -71,5 +71,5 @@ 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 = client.post(reverse('api_v0:user:token_refresh'), {'token': token})
         assert response.status_code == 400
diff --git a/backend/tests/baserow/api/v0/user/test_user_views.py b/backend/tests/baserow/api/v0/user/test_user_views.py
index 25f9ba952..50a796475 100644
--- a/backend/tests/baserow/api/v0/user/test_user_views.py
+++ b/backend/tests/baserow/api/v0/user/test_user_views.py
@@ -9,7 +9,7 @@ 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'
@@ -21,7 +21,7 @@ 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'
@@ -30,7 +30,7 @@ def test_create_user(client):
     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'
     })
 
diff --git a/backend/tests/baserow/core/test_core_managers.py b/backend/tests/baserow/core/test_core_managers.py
new file mode 100644
index 000000000..aeea1da2d
--- /dev/null
+++ b/backend/tests/baserow/core/test_core_managers.py
@@ -0,0 +1,24 @@
+import pytest
+
+from baserow.core.models import Group
+
+
+@pytest.mark.django_db
+def test_groups_of_user(data_fixture):
+    user_1 = data_fixture.create_user()
+    group_user_1 = data_fixture.create_user_group(user=user_1, order=1)
+    group_user_2 = data_fixture.create_user_group(user=user_1, order=2)
+    group_user_3 = data_fixture.create_user_group(user=user_1, order=0)
+
+    user_2 = data_fixture.create_user()
+    group_user_4 = data_fixture.create_user_group(user=user_2, order=0)
+
+    groups_user_1 = Group.objects.of_user(user=user_1)
+    assert len(groups_user_1) == 3
+    assert groups_user_1[0].id == group_user_3.id
+    assert groups_user_1[1].id == group_user_1.id
+    assert groups_user_1[2].id == group_user_2.id
+
+    groups_user_2 = Group.objects.of_user(user=user_2)
+    assert len(groups_user_2) == 1
+    assert groups_user_2[0].id == group_user_4.id
diff --git a/backend/tests/baserow/core/test_core_models.py b/backend/tests/baserow/core/test_core_models.py
new file mode 100644
index 000000000..432bff385
--- /dev/null
+++ b/backend/tests/baserow/core/test_core_models.py
@@ -0,0 +1,13 @@
+import pytest
+
+from baserow.core.models import GroupUser
+
+
+@pytest.mark.django_db
+def test_group_user_get_next_order(data_fixture):
+    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
diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py
index 1b2f9b50e..13a51aaef 100644
--- a/backend/tests/fixtures/__init__.py
+++ b/backend/tests/fixtures/__init__.py
@@ -1,5 +1,8 @@
+from faker import Faker
+
 from .user import UserFixtures
+from .group import GroupFixtures
 
 
-class Fixtures(UserFixtures):
-    pass
+class Fixtures(UserFixtures, GroupFixtures):
+    fake = Faker()
diff --git a/backend/tests/fixtures/group.py b/backend/tests/fixtures/group.py
new file mode 100644
index 000000000..c34098cf5
--- /dev/null
+++ b/backend/tests/fixtures/group.py
@@ -0,0 +1,24 @@
+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)
+
+        kwargs.setdefault('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):
+        kwargs.setdefault('group', self.create_group())
+        kwargs.setdefault('user', self.create_user())
+        kwargs.setdefault('order', 0)
+        return GroupUser.objects.create(**kwargs)
diff --git a/backend/tests/fixtures/user.py b/backend/tests/fixtures/user.py
index 2b58a1c68..ebd6b4951 100644
--- a/backend/tests/fixtures/user.py
+++ b/backend/tests/fixtures/user.py
@@ -1,16 +1,14 @@
 from django.contrib.auth import get_user_model
-from faker import Faker
 
 
-fake = Faker()
 User = get_user_model()
 
 
 class UserFixtures:
     def create_user(self, **kwargs):
-        kwargs.setdefault('email', fake.email())
+        kwargs.setdefault('email', self.fake.email())
         kwargs.setdefault('username', kwargs['email'])
-        kwargs.setdefault('first_name', fake.name())
+        kwargs.setdefault('first_name', self.fake.name())
         kwargs.setdefault('password', 'password')
 
         user = User(**kwargs)
diff --git a/web-frontend/assets/scss/abstracts/_variables.scss b/web-frontend/assets/scss/abstracts/_variables.scss
index 01ca4e9ac..6167df086 100644
--- a/web-frontend/assets/scss/abstracts/_variables.scss
+++ b/web-frontend/assets/scss/abstracts/_variables.scss
@@ -63,11 +63,12 @@ $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 the context must always be the highest because they can open in a
-// modal.
-$z-index-context: 7;
+// The z-index of modal and context must always be the same and the highest because they
+// can be nested inside each other. The order in html decided which must be shown over
+// the other.
+$z-index-modal: 6;
+$z-index-context: 6;
 
 // normalize overrides
 $base-font-family: $text-font-stack;
diff --git a/web-frontend/assets/scss/components/_context.scss b/web-frontend/assets/scss/components/_context.scss
index 60d93208d..f95ce3106 100644
--- a/web-frontend/assets/scss/components/_context.scss
+++ b/web-frontend/assets/scss/components/_context.scss
@@ -8,6 +8,17 @@
   box-shadow: 0 2px 6px 0 rgba($black, 0.16);
 }
 
+.context-loading {
+  display: flex;
+  justify-content: center;
+  padding: 32px 0;
+}
+
+.context-description {
+  padding: 32px 0;
+  text-align: center;
+}
+
 .context-menu-title {
   color: $color-neutral-600;
   padding: 12px 8px 2px 8px;
diff --git a/web-frontend/assets/scss/components/_loading.scss b/web-frontend/assets/scss/components/_loading.scss
new file mode 100644
index 000000000..15df1c6b3
--- /dev/null
+++ b/web-frontend/assets/scss/components/_loading.scss
@@ -0,0 +1,11 @@
+.loading {
+  position: relative;
+  display: block;
+  width: 1.4em;
+  height: 1.4em;
+  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);
+}
diff --git a/web-frontend/assets/scss/default.scss b/web-frontend/assets/scss/default.scss
index a1ad1f74e..dda193612 100755
--- a/web-frontend/assets/scss/default.scss
+++ b/web-frontend/assets/scss/default.scss
@@ -26,3 +26,4 @@
 @import 'components/views/grid/boolean';
 @import 'components/views/grid/number';
 @import 'components/box_page';
+@import 'components/loading';
diff --git a/web-frontend/components/Context.vue b/web-frontend/components/Context.vue
index 89a206594..96ec8b71b 100644
--- a/web-frontend/components/Context.vue
+++ b/web-frontend/components/Context.vue
@@ -6,9 +6,11 @@
 
 <script>
 import { isElement } from '@/utils/dom'
+import MoveToBody from '@/mixins/moveToBody'
 
 export default {
   name: 'Context',
+  mixins: [MoveToBody],
   data() {
     return {
       open: false,
@@ -16,36 +18,6 @@ export default {
       children: []
     }
   },
-  /**
-   * 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.$el)
-      }
-      $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)
-    }
-  },
   methods: {
     /**
      * Toggles the open state of the context menu.
@@ -54,6 +26,8 @@ export default {
      *                    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.
@@ -136,6 +110,9 @@ export default {
       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
@@ -150,6 +127,14 @@ export default {
         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'
       }
@@ -175,6 +160,14 @@ export default {
         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
     },
     /**
diff --git a/web-frontend/components/Modal.vue b/web-frontend/components/Modal.vue
new file mode 100644
index 000000000..ad738d319
--- /dev/null
+++ b/web-frontend/components/Modal.vue
@@ -0,0 +1,66 @@
+<template>
+  <div
+    ref="modalWrapper"
+    :class="{ hidden: !open }"
+    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>
+</template>
+
+<script>
+import MoveToBody from '@/mixins/moveToBody'
+
+export default {
+  name: 'CreateGroupModal',
+  mixins: [MoveToBody],
+  data() {
+    return {
+      open: false
+    }
+  },
+  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
+    },
+    /**
+     * Hide the modal.
+     */
+    hide() {
+      this.open = false
+    },
+    /**
+     * 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()
+      }
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/group/CreateGroupModal.vue b/web-frontend/components/group/CreateGroupModal.vue
new file mode 100644
index 000000000..bcaf4b04b
--- /dev/null
+++ b/web-frontend/components/group/CreateGroupModal.vue
@@ -0,0 +1,27 @@
+<template>
+  <Modal ref="createGroupModal">
+    <h2 class="box-title">Create new group</h2>
+    <form>
+      <div class="control">
+        <label class="control-label">
+          <i class="fas fa-font"></i>
+          Name
+        </label>
+        <div class="control-elements">
+          <input type="text" class="input input-large" />
+        </div>
+      </div>
+    </form>
+  </Modal>
+</template>
+
+<script>
+export default {
+  name: 'CreateGroupModal',
+  methods: {
+    show() {
+      this.$refs.createGroupModal.show()
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/group/GroupsContext.vue b/web-frontend/components/group/GroupsContext.vue
new file mode 100644
index 000000000..fd2591ddd
--- /dev/null
+++ b/web-frontend/components/group/GroupsContext.vue
@@ -0,0 +1,89 @@
+<template>
+  <Context ref="groupContext" class="select">
+    <div class="select-search">
+      <i class="select-search-icon fas fa-search"></i>
+      <input
+        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 && groups.length > 0" class="select-items">
+      <li v-for="group in groups" :key="group.id" class="select-item">
+        <a href="#" class="select-item-link">{{ group.name }}</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 && groups.length == 0" class="context-description">
+      No results found
+    </div>
+    <Context ref="groupsItemContext">
+      <ul class="context-menu">
+        <li>
+          <a href="#">
+            <i class="context-menu-icon fas fa-fw fa-pen"></i>
+            Rename group
+          </a>
+        </li>
+        <li>
+          <a href="#">
+            <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'
+
+export default {
+  name: 'GroupsItemContext',
+  components: {
+    CreateGroupModal
+  },
+  data() {
+    return {
+      open: false
+    }
+  },
+  computed: {
+    ...mapState({
+      groups: state => state.group.items
+    }),
+    ...mapGetters({
+      isLoading: 'group/isLoading'
+    })
+  },
+  methods: {
+    toggle(...args) {
+      this.$store.dispatch('group/loadAll')
+      this.$refs.groupContext.toggle(...args)
+    },
+    toggleContext(groupId) {
+      const target = this.$refs['groupOptions' + groupId][0]
+      this.$refs.groupsItemContext.toggle(target, 'bottom', 'right', 0)
+    }
+  }
+}
+</script>
diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js
index 5bdb09e02..3d852869d 100644
--- a/web-frontend/config/nuxt.config.base.js
+++ b/web-frontend/config/nuxt.config.base.js
@@ -25,7 +25,11 @@ 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/auth.js' },
+    { src: '@/plugins/vuelidate.js' }
+  ],
 
   /*
    ** Nuxt.js modules
diff --git a/web-frontend/intellij-idea.webpack.config.js b/web-frontend/intellij-idea.webpack.config.js
new file mode 100644
index 000000000..a2fe6fbfc
--- /dev/null
+++ b/web-frontend/intellij-idea.webpack.config.js
@@ -0,0 +1,16 @@
+/** This file can be used in combination with intellij idea so the @ path resolves **/
+
+const path = require('path')
+
+module.exports = {
+  resolve: {
+    extensions: ['.js', '.json', '.vue', '.ts'],
+    root: path.resolve(__dirname),
+    alias: {
+      '@': path.resolve(__dirname),
+      '@@': path.resolve(__dirname),
+      '~': path.resolve(__dirname),
+      '~~': path.resolve(__dirname)
+    }
+  }
+}
diff --git a/web-frontend/layouts/app.vue b/web-frontend/layouts/app.vue
index a2c921b46..8215dd929 100644
--- a/web-frontend/layouts/app.vue
+++ b/web-frontend/layouts/app.vue
@@ -9,68 +9,15 @@
           </nuxt-link>
         </li>
         <li class="menu-item">
-          <a href="#" class="menu-link" data-context=".select">
+          <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>
-          <div class="select hidden">
-            <div class="select-search">
-              <i class="select-search-icon fas fa-search"></i>
-              <input
-                type="text"
-                class="select-search-input"
-                placeholder="Search views"
-              />
-            </div>
-            <ul class="select-items">
-              <li class="select-item active">
-                <a href="#" class="select-item-link">Group name 1</a>
-                <a href="#" class="select-item-options" data-context=".context">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-                <div class="context hidden">
-                  <ul class="context-menu">
-                    <li>
-                      <a href="#">
-                        <i class="context-menu-icon fas fa-fw fa-pen"></i>
-                        Rename group
-                      </a>
-                    </li>
-                    <li>
-                      <a href="#">
-                        <i class="context-menu-icon fas fa-fw fa-trash"></i>
-                        Delete group
-                      </a>
-                    </li>
-                  </ul>
-                </div>
-              </li>
-              <li class="select-item">
-                <a href="#" class="select-item-link">Group name 2</a>
-                <a href="#" class="select-item-options">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-              </li>
-              <li class="select-item">
-                <a href="#" class="select-item-link">Group name 3</a>
-                <a href="#" class="select-item-options">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-              </li>
-              <li class="select-item">
-                <a href="#" class="select-item-link">Group name 4</a>
-                <a href="#" class="select-item-options">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-              </li>
-            </ul>
-            <div class="select-footer">
-              <a href="#" class="select-footer-button">
-                <i class="fas fa-plus"></i>
-                Do something
-              </a>
-            </div>
-          </div>
+          <GroupsContext ref="groupSelect"></GroupsContext>
         </li>
       </ul>
       <ul class="menu-items">
@@ -108,130 +55,6 @@
           <div class="sidebar-title">
             <img src="@/static/img/logo.svg" alt="" />
           </div>
-          <div class="sidebar-group-title">Group name 1</div>
-          <ul class="tree">
-            <li class="tree-item">
-              <div class="tree-action">
-                <a href="#" class="tree-link">
-                  <i class="tree-type fas fa-database"></i>
-                  Vehicles
-                </a>
-                <a href="#" class="tree-options" data-context=".context">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-                <div class="context hidden">
-                  <div class="context-menu-title">Vehicles</div>
-                  <ul class="context-menu">
-                    <li>
-                      <a href="#">
-                        <i class="context-menu-icon fas fa-fw fa-pen"></i>
-                        Rename database
-                      </a>
-                    </li>
-                    <li>
-                      <a href="#">
-                        <i class="context-menu-icon fas fa-fw fa-trash"></i>
-                        Delete table
-                      </a>
-                    </li>
-                  </ul>
-                </div>
-              </div>
-            </li>
-            <li class="tree-item">
-              <div class="tree-action">
-                <a href="#" class="tree-link">
-                  <i class="tree-type fas fa-angle-right"></i>
-                  Map nummer 1
-                </a>
-              </div>
-            </li>
-            <li class="tree-item active">
-              <div class="tree-action">
-                <a href="#" class="tree-link">
-                  <i class="tree-type fas fa-database"></i>
-                  Webshop
-                </a>
-                <a href="#" class="tree-options">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-              </div>
-              <ul class="tree-subs">
-                <li class="tree-sub active">
-                  <a href="#" class="tree-sub-link">Customers</a>
-                  <a href="#" class="tree-options">
-                    <i class="fas fa-ellipsis-v"></i>
-                  </a>
-                </li>
-                <li class="tree-sub">
-                  <a href="#" class="tree-sub-link">Products very long name</a>
-                  <a href="#" class="tree-options">
-                    <i class="fas fa-ellipsis-v"></i>
-                  </a>
-                </li>
-                <li class="tree-sub">
-                  <a href="#" class="tree-sub-link">Categories</a>
-                  <a href="#" class="tree-options">
-                    <i class="fas fa-ellipsis-v"></i>
-                  </a>
-                </li>
-              </ul>
-            </li>
-            <li class="tree-item">
-              <div class="tree-action">
-                <a href="#" class="tree-link">
-                  <i class="tree-type fas fa-angle-down"></i>
-                  Map nummer 1
-                </a>
-              </div>
-              <ul class="tree">
-                <li class="tree-item">
-                  <div class="tree-action">
-                    <a href="#" class="tree-link">
-                      <i class="tree-type fas fa-database"></i>
-                      Vehicles
-                    </a>
-                    <a href="#" class="tree-options">
-                      <i class="fas fa-ellipsis-v"></i>
-                    </a>
-                  </div>
-                </li>
-                <li class="tree-item">
-                  <div class="tree-action">
-                    <a href="#" class="tree-link">
-                      <i class="tree-type fas fa-database"></i>
-                      Something
-                    </a>
-                    <a href="#" class="tree-options">
-                      <i class="fas fa-ellipsis-v"></i>
-                    </a>
-                  </div>
-                </li>
-              </ul>
-            </li>
-            <li class="tree-item">
-              <div class="tree-action">
-                <a href="#" class="tree-link">
-                  <i class="tree-type fas fa-database"></i>
-                  Vehicles with very long name and that is not good.
-                </a>
-                <a href="#" class="tree-options">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-              </div>
-            </li>
-            <li class="tree-item">
-              <div class="tree-action">
-                <a href="#" class="tree-link">
-                  <i class="tree-type fas fa-database"></i>
-                  Something else
-                </a>
-                <a href="#" class="tree-options">
-                  <i class="fas fa-ellipsis-v"></i>
-                </a>
-              </div>
-            </li>
-          </ul>
         </nav>
       </div>
       <div class="sidebar-footer">
@@ -250,13 +73,13 @@
 <script>
 import { mapActions, mapGetters } from 'vuex'
 
-import Context from '@/components/Context'
+import GroupsContext from '@/components/group/GroupsContext'
 
 export default {
   layout: 'default',
   middleware: 'authenticated',
   components: {
-    Context
+    GroupsContext
   },
   computed: {
     ...mapGetters({
diff --git a/web-frontend/mixins/moveToBody.js b/web-frontend/mixins/moveToBody.js
new file mode 100644
index 000000000..354ff623d
--- /dev/null
+++ b/web-frontend/mixins/moveToBody.js
@@ -0,0 +1,32 @@
+export default {
+  /**
+   * Because we don't want the parent context to close when a user clicks 'outside' that
+   * element and in the child element we need to register the child with their parent to
+   * prevent this.
+   */
+  mounted() {
+    let $parent = this.$parent
+    while ($parent !== undefined) {
+      if ($parent.registerContextChild) {
+        $parent.registerContextChild(this.$el)
+      }
+      $parent = $parent.$parent
+    }
+
+    // Move the rendered element to the top of the body so it can be positioned over any
+    // other element.
+    const body = document.body
+    body.insertBefore(this.$el, body.firstChild)
+  },
+  /**
+   * Make sure the context menu is not open and all the events on the body are removed
+   * and that the element is removed from the body.
+   */
+  destroyed() {
+    this.hide()
+
+    if (this.$el.parentNode) {
+      this.$el.parentNode.removeChild(this.$el)
+    }
+  }
+}
diff --git a/web-frontend/plugins/auth.js b/web-frontend/plugins/auth.js
index 7ee29cba2..cfc730949 100644
--- a/web-frontend/plugins/auth.js
+++ b/web-frontend/plugins/auth.js
@@ -6,7 +6,7 @@ export default function({ store }) {
   client.interceptors.request.use(config => {
     if (store.getters['auth/isAuthenticated']) {
       const token = store.getters['auth/token']
-      config.headers.Authorization = `JWT: ${token}`
+      config.headers.Authorization = `JWT ${token}`
     }
     return config
   })
diff --git a/web-frontend/plugins/global.js b/web-frontend/plugins/global.js
new file mode 100644
index 000000000..f39e8e57a
--- /dev/null
+++ b/web-frontend/plugins/global.js
@@ -0,0 +1,7 @@
+import Vue from 'vue'
+
+import Context from '@/components/Context'
+import Modal from '@/components/Modal'
+
+Vue.component('Context', Context)
+Vue.component('Modal', Modal)
diff --git a/web-frontend/services/group.js b/web-frontend/services/group.js
new file mode 100644
index 000000000..111b93f31
--- /dev/null
+++ b/web-frontend/services/group.js
@@ -0,0 +1,7 @@
+import { client } from './client'
+
+export default {
+  fetchAll() {
+    return client.get('/groups/')
+  }
+}
diff --git a/web-frontend/store/group.js b/web-frontend/store/group.js
new file mode 100644
index 000000000..6ae4ea680
--- /dev/null
+++ b/web-frontend/store/group.js
@@ -0,0 +1,51 @@
+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
+  }
+}
+
+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', false)
+        commit('SET_ITEMS', data)
+      })
+      .catch(() => {
+        commit('SET_ITEMS', [])
+      })
+      .then(() => {
+        commit('SET_LOADING', false)
+      })
+  }
+}
+
+export const getters = {
+  isLoaded(state) {
+    return state.loaded
+  },
+  isLoading(state) {
+    return state.loading
+  }
+}