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