mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-16 18:07:47 +00:00
Merge branch '13-application-web-frontend' into 'develop'
Resolve "Create application web frontend" Closes #13 See merge request bramw/baserow!8
This commit is contained in:
commit
b36771f64d
40 changed files with 1513 additions and 174 deletions
backend
src/baserow/api/v0/applications
tests/baserow/api/v0/applications
old-web-frontend/public
web-frontend
assets/scss/components
components
config
core
filters
layouts
middleware
mixins
modules/database
pages/app
plugins
services
store
utils
|
@ -1,15 +1,17 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from baserow.api.v0.groups.serializers import GroupSerializer
|
||||||
from baserow.core.applications import registry
|
from baserow.core.applications import registry
|
||||||
from baserow.core.models import Application
|
from baserow.core.models import Application
|
||||||
|
|
||||||
|
|
||||||
class ApplicationSerializer(serializers.ModelSerializer):
|
class ApplicationSerializer(serializers.ModelSerializer):
|
||||||
type = serializers.SerializerMethodField()
|
type = serializers.SerializerMethodField()
|
||||||
|
group = GroupSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Application
|
model = Application
|
||||||
fields = ('id', 'name', 'order', 'type')
|
fields = ('id', 'name', 'order', 'type', 'group')
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'id': {
|
'id': {
|
||||||
'read_only': True
|
'read_only': True
|
||||||
|
|
|
@ -56,6 +56,15 @@ class ApplicationView(APIView):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
core_handler = CoreHandler()
|
core_handler = CoreHandler()
|
||||||
|
|
||||||
|
def get(self, request, application_id):
|
||||||
|
"""Selects a single application and responds with a serialized version."""
|
||||||
|
application = get_object_or_404(
|
||||||
|
Application.objects.select_related('group'),
|
||||||
|
pk=application_id, group__users__in=[request.user]
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(ApplicationSerializer(application).data)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@validate_body(ApplicationUpdateSerializer)
|
@validate_body(ApplicationUpdateSerializer)
|
||||||
@map_exceptions({
|
@map_exceptions({
|
||||||
|
|
|
@ -94,6 +94,46 @@ def test_create_application(api_client, data_fixture):
|
||||||
assert response_json['order'] == database.order
|
assert response_json['order'] == database.order
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_application(api_client, data_fixture):
|
||||||
|
user, token = data_fixture.create_user_and_token()
|
||||||
|
user_2, token_2 = data_fixture.create_user_and_token()
|
||||||
|
group = data_fixture.create_group(user=user)
|
||||||
|
group_2 = data_fixture.create_group(user=user_2)
|
||||||
|
application = data_fixture.create_database_application(group=group)
|
||||||
|
application_2 = data_fixture.create_database_application(group=group_2)
|
||||||
|
|
||||||
|
url = reverse('api_v0:applications:item',
|
||||||
|
kwargs={'application_id': application_2.id})
|
||||||
|
response = api_client.get(
|
||||||
|
url,
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
url = reverse('api_v0:applications:item',
|
||||||
|
kwargs={'application_id': 99999})
|
||||||
|
response = api_client.get(
|
||||||
|
url,
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
url = reverse('api_v0:applications:item',
|
||||||
|
kwargs={'application_id': application.id})
|
||||||
|
response = api_client.get(
|
||||||
|
url,
|
||||||
|
format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||||
|
)
|
||||||
|
response_json = response.json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response_json['id'] == application.id
|
||||||
|
assert response_json['group']['id'] == group.id
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_update_application(api_client, data_fixture):
|
def test_update_application(api_client, data_fixture):
|
||||||
user, token = data_fixture.create_user_and_token()
|
user, token = data_fixture.create_user_and_token()
|
||||||
|
|
|
@ -57,7 +57,7 @@
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="select-item">
|
<li class="select-item select-item-loading">
|
||||||
<a href="#" class="select-item-link">Group name 3</a>
|
<a href="#" class="select-item-link">Group name 3</a>
|
||||||
<a href="#" class="select-item-options">
|
<a href="#" class="select-item-options">
|
||||||
<i class="fas fa-ellipsis-v"></i>
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
@ -102,7 +102,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-group-title">Group name 1</div>
|
<div class="sidebar-group-title">Group name 1</div>
|
||||||
<ul class="tree">
|
<ul class="tree">
|
||||||
<li class="tree-item">
|
<li class="tree-item tree-item-loading">
|
||||||
<div class="tree-action">
|
<div class="tree-action">
|
||||||
<a href="#" class="tree-link">
|
<a href="#" class="tree-link">
|
||||||
<i class="tree-type fas fa-database"></i>
|
<i class="tree-type fas fa-database"></i>
|
||||||
|
@ -139,7 +139,7 @@
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="tree-item active">
|
<li class="tree-item active">
|
||||||
<div class="tree-action">
|
<div class="tree-action tree-item-loading">
|
||||||
<a href="#" class="tree-link">
|
<a href="#" class="tree-link">
|
||||||
<i class="tree-type fas fa-database"></i>
|
<i class="tree-type fas fa-database"></i>
|
||||||
Webshop
|
Webshop
|
||||||
|
@ -169,7 +169,39 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="tree-item">
|
<li class="tree-item tree-item-loading active">
|
||||||
|
<div class="tree-action tree-item-loading">
|
||||||
|
<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 tree-item-loading">
|
||||||
<div class="tree-action">
|
<div class="tree-action">
|
||||||
<a href="#" class="tree-link">
|
<a href="#" class="tree-link">
|
||||||
<i class="tree-type fas fa-angle-down"></i>
|
<i class="tree-type fas fa-angle-down"></i>
|
||||||
|
@ -188,7 +220,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="tree-item">
|
<li class="tree-item tree-item-loading">
|
||||||
<div class="tree-action">
|
<div class="tree-action">
|
||||||
<a href="#" class="tree-link">
|
<a href="#" class="tree-link">
|
||||||
<i class="tree-type fas fa-database"></i>
|
<i class="tree-type fas fa-database"></i>
|
||||||
|
|
|
@ -63,10 +63,17 @@
|
||||||
background-color: $color-neutral-100;
|
background-color: $color-neutral-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.select-item-loading::before {
|
||||||
|
content: " ";
|
||||||
|
|
||||||
|
@include loading(14px);
|
||||||
|
@include absolute(9px, 9px, auto, auto);
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: $color-primary-100;
|
background-color: $color-primary-100;
|
||||||
|
|
||||||
&::after {
|
&:not(.select-item-loading)::after {
|
||||||
@extend .fas;
|
@extend .fas;
|
||||||
@extend %select-item-size;
|
@extend %select-item-size;
|
||||||
|
|
||||||
|
@ -82,17 +89,6 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.select-item-loading {
|
|
||||||
background-color: $color-neutral-100;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: " ";
|
|
||||||
|
|
||||||
@include loading(14px);
|
|
||||||
@include absolute(9px, 9px, auto, auto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-item-link {
|
.select-item-link {
|
||||||
|
|
|
@ -50,3 +50,14 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-new {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $color-neutral-300;
|
||||||
|
margin-left: 7px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-neutral-500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
.tree {
|
.tree {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0 0 12px;
|
||||||
|
|
||||||
.tree-item & {
|
.tree-item & {
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
@ -18,6 +18,13 @@
|
||||||
&.active {
|
&.active {
|
||||||
background-color: $color-primary-100;
|
background-color: $color-primary-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.tree-item-loading::after {
|
||||||
|
content: " ";
|
||||||
|
|
||||||
|
@include loading(14px);
|
||||||
|
@include absolute(9px, 9px, auto, auto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
%tree-size {
|
%tree-size {
|
||||||
|
@ -122,6 +129,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-sub-add {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 10px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $color-neutral-300;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $color-neutral-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tree-options {
|
.tree-options {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -141,4 +160,8 @@
|
||||||
:hover > & {
|
:hover > & {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-item-loading > .tree-action > & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
opener: null,
|
opener: null
|
||||||
children: []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -81,9 +80,9 @@ export default {
|
||||||
!isElement(this.opener, event.target) &&
|
!isElement(this.opener, event.target) &&
|
||||||
// If the click was not inside one of the context children of this context
|
// If the click was not inside one of the context children of this context
|
||||||
// menu.
|
// menu.
|
||||||
!this.children.some(component =>
|
!this.moveToBody.children.some(child => {
|
||||||
isElement(component.$el, event.target)
|
return isElement(child.$el, event.target)
|
||||||
)
|
})
|
||||||
) {
|
) {
|
||||||
this.hide()
|
this.hide()
|
||||||
}
|
}
|
||||||
|
@ -96,6 +95,7 @@ export default {
|
||||||
hide() {
|
hide() {
|
||||||
this.opener = null
|
this.opener = null
|
||||||
this.open = false
|
this.open = false
|
||||||
|
this.$emit('hidden')
|
||||||
|
|
||||||
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
|
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
|
||||||
},
|
},
|
||||||
|
@ -171,13 +171,6 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
return positions
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<transition name="fade">
|
<div
|
||||||
<div
|
v-if="open"
|
||||||
v-if="open"
|
ref="modalWrapper"
|
||||||
ref="modalWrapper"
|
class="modal-wrapper"
|
||||||
class="modal-wrapper"
|
@click="outside($event)"
|
||||||
@click="outside($event)"
|
>
|
||||||
>
|
<div class="modal-box">
|
||||||
<div class="modal-box">
|
<a class="modal-close" @click="hide()">
|
||||||
<a class="modal-close" @click="hide()">
|
<i class="fas fa-times"></i>
|
||||||
<i class="fas fa-times"></i>
|
</a>
|
||||||
</a>
|
<slot></slot>
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -56,7 +54,16 @@ export default {
|
||||||
* Hide the modal.
|
* Hide the modal.
|
||||||
*/
|
*/
|
||||||
hide() {
|
hide() {
|
||||||
this.open = false
|
// This is a temporary fix. What happens is the model is opened by a context menu
|
||||||
|
// item and the user closes the modal, the element is first deleted and then the
|
||||||
|
// click outside event of the context is fired. It then checks if the click was
|
||||||
|
// inside one of his children, but because the modal element doesn't exists
|
||||||
|
// anymore it thinks it was outside, so is closes the context menu which we don't
|
||||||
|
// want automatically.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.open = false
|
||||||
|
})
|
||||||
|
this.$emit('hidden')
|
||||||
window.removeEventListener('keyup', this.keyup)
|
window.removeEventListener('keyup', this.keyup)
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -42,6 +42,9 @@ export default {
|
||||||
values: {
|
values: {
|
||||||
name: { required }
|
name: { required }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$refs.name.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<Context class="select">
|
<Context ref="groupsContext" class="select">
|
||||||
<div class="select-search">
|
<div class="select-search">
|
||||||
<i class="select-search-icon fas fa-search"></i>
|
<i class="select-search-icon fas fa-search"></i>
|
||||||
<input
|
<input
|
||||||
|
@ -13,28 +13,12 @@
|
||||||
<div class="loading"></div>
|
<div class="loading"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="!isLoading && isLoaded && groups.length > 0" class="select-items">
|
<ul v-if="!isLoading && isLoaded && groups.length > 0" class="select-items">
|
||||||
<li
|
<GroupsContextItem
|
||||||
v-for="group in searchAndSort(groups)"
|
v-for="group in searchAndSort(groups)"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
:ref="'groupSelect' + group.id"
|
:group="group"
|
||||||
class="select-item"
|
@selected="hide"
|
||||||
>
|
></GroupsContextItem>
|
||||||
<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>
|
</ul>
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && isLoaded && groups.length == 0"
|
v-if="!isLoading && isLoaded && groups.length == 0"
|
||||||
|
@ -42,22 +26,6 @@
|
||||||
>
|
>
|
||||||
No results found
|
No results found
|
||||||
</div>
|
</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">
|
<div class="select-footer">
|
||||||
<a class="select-footer-button" @click="$refs.createGroupModal.show()">
|
<a class="select-footer-button" @click="$refs.createGroupModal.show()">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
|
@ -72,18 +40,19 @@
|
||||||
import { mapGetters, mapState } from 'vuex'
|
import { mapGetters, mapState } from 'vuex'
|
||||||
|
|
||||||
import CreateGroupModal from '@/components/group/CreateGroupModal'
|
import CreateGroupModal from '@/components/group/CreateGroupModal'
|
||||||
|
import GroupsContextItem from '@/components/group/GroupsContextItem'
|
||||||
import context from '@/mixins/context'
|
import context from '@/mixins/context'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GroupsItemContext',
|
name: 'GroupsContext',
|
||||||
components: {
|
components: {
|
||||||
CreateGroupModal
|
CreateGroupModal,
|
||||||
|
GroupsContextItem
|
||||||
},
|
},
|
||||||
mixins: [context],
|
mixins: [context],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
query: '',
|
query: ''
|
||||||
contextId: -1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -96,15 +65,14 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* When the groups context select is opened for the for the first time we must make
|
||||||
|
* sure that all the groups are already loaded or going to be loaded.
|
||||||
|
*/
|
||||||
toggle(...args) {
|
toggle(...args) {
|
||||||
this.$store.dispatch('group/loadAll')
|
this.$store.dispatch('group/loadAll')
|
||||||
this.getRootContext().toggle(...args)
|
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) {
|
searchAndSort(groups) {
|
||||||
const query = this.query
|
const query = this.query
|
||||||
|
|
||||||
|
@ -115,39 +83,6 @@ export default {
|
||||||
// .sort((a, b) => {
|
// .sort((a, b) => {
|
||||||
// return a.order - b.order
|
// 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')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
96
web-frontend/components/group/GroupsContextItem.vue
Normal file
96
web-frontend/components/group/GroupsContextItem.vue
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="select-item"
|
||||||
|
:class="{
|
||||||
|
active: group._.selected,
|
||||||
|
'select-item-loading': group._.loading
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="loading-overlay"></div>
|
||||||
|
<a class="select-item-link" @click="selectGroup(group)">
|
||||||
|
<Editable
|
||||||
|
ref="rename"
|
||||||
|
:value="group.name"
|
||||||
|
@change="renameGroup(group, $event)"
|
||||||
|
></Editable>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
ref="contextLink"
|
||||||
|
class="select-item-options"
|
||||||
|
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</a>
|
||||||
|
<Context ref="context">
|
||||||
|
<ul class="context-menu">
|
||||||
|
<li>
|
||||||
|
<a @click="enableRename()">
|
||||||
|
<i class="context-menu-icon fas fa-fw fa-pen"></i>
|
||||||
|
Rename group
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a @click="deleteGroup(group)">
|
||||||
|
<i class="context-menu-icon fas fa-fw fa-trash"></i>
|
||||||
|
Delete group
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Context>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'GroupsContextItem',
|
||||||
|
props: {
|
||||||
|
group: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setLoading(group, value) {
|
||||||
|
this.$store.dispatch('group/setItemLoading', { group, value: value })
|
||||||
|
},
|
||||||
|
enableRename() {
|
||||||
|
this.$refs.context.hide()
|
||||||
|
this.$refs.rename.edit()
|
||||||
|
},
|
||||||
|
renameGroup(group, event) {
|
||||||
|
this.setLoading(group, true)
|
||||||
|
|
||||||
|
this.$store
|
||||||
|
.dispatch('group/update', {
|
||||||
|
group,
|
||||||
|
values: {
|
||||||
|
name: event.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// If something is going wrong we will reset the original value
|
||||||
|
this.$refs.rename.set(event.oldValue)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.setLoading(group, false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectGroup(group) {
|
||||||
|
this.setLoading(group, true)
|
||||||
|
|
||||||
|
this.$store.dispatch('group/select', group).then(() => {
|
||||||
|
this.setLoading(group, false)
|
||||||
|
this.$emit('selected')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteGroup(group) {
|
||||||
|
this.$refs.context.hide()
|
||||||
|
this.setLoading(group, true)
|
||||||
|
|
||||||
|
this.$store.dispatch('group/delete', group).then(() => {
|
||||||
|
this.setLoading(group, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
50
web-frontend/components/sidebar/ApplicationForm.vue
Normal file
50
web-frontend/components/sidebar/ApplicationForm.vue
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<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: 'CreateApplicationForm',
|
||||||
|
mixins: [form],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
values: {
|
||||||
|
name: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$refs.name.focus()
|
||||||
|
},
|
||||||
|
validations: {
|
||||||
|
values: {
|
||||||
|
name: { required }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
49
web-frontend/components/sidebar/CreateApplicationContext.vue
Normal file
49
web-frontend/components/sidebar/CreateApplicationContext.vue
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<template>
|
||||||
|
<Context>
|
||||||
|
<ul class="context-menu">
|
||||||
|
<li v-for="(application, type) in applications" :key="type">
|
||||||
|
<a
|
||||||
|
:ref="'createApplicationModalToggle' + type"
|
||||||
|
@click="toggleCreateApplicationModal(type)"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="context-menu-icon fas fa-fw"
|
||||||
|
:class="'fa-' + application.iconClass"
|
||||||
|
></i>
|
||||||
|
{{ application.name }}
|
||||||
|
</a>
|
||||||
|
<CreateApplicationModal
|
||||||
|
:ref="'createApplicationModal' + type"
|
||||||
|
:application="application"
|
||||||
|
@created="hide"
|
||||||
|
></CreateApplicationModal>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Context>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
|
import CreateApplicationModal from '@/components/sidebar/CreateApplicationModal'
|
||||||
|
import context from '@/mixins/context'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CreateApplicationContext',
|
||||||
|
components: {
|
||||||
|
CreateApplicationModal
|
||||||
|
},
|
||||||
|
mixins: [context],
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
applications: state => state.application.applications
|
||||||
|
})
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleCreateApplicationModal(type) {
|
||||||
|
const target = this.$refs['createApplicationModalToggle' + type][0]
|
||||||
|
this.$refs['createApplicationModal' + type][0].toggle(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
60
web-frontend/components/sidebar/CreateApplicationModal.vue
Normal file
60
web-frontend/components/sidebar/CreateApplicationModal.vue
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<h2 class="box-title">Create new {{ application.name | lowercase }}</h2>
|
||||||
|
<component
|
||||||
|
:is="application.getApplicationFormComponent()"
|
||||||
|
ref="applicationForm"
|
||||||
|
@submitted="submitted"
|
||||||
|
>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="align-right">
|
||||||
|
<button
|
||||||
|
class="button button-large"
|
||||||
|
:class="{ 'button-loading': loading }"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
Add {{ application.name | lowercase }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import modal from '@/mixins/modal'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CreateApplicationModal',
|
||||||
|
mixins: [modal],
|
||||||
|
props: {
|
||||||
|
application: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submitted(values) {
|
||||||
|
this.loading = true
|
||||||
|
this.$store
|
||||||
|
.dispatch('application/create', {
|
||||||
|
type: this.application.type,
|
||||||
|
values: values
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.loading = false
|
||||||
|
this.$emit('created')
|
||||||
|
this.hide()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
54
web-frontend/components/sidebar/Sidebar.vue
Normal file
54
web-frontend/components/sidebar/Sidebar.vue
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="hasSelectedGroup">
|
||||||
|
<div class="sidebar-group-title">{{ selectedGroup.name }}</div>
|
||||||
|
<ul class="tree">
|
||||||
|
<SidebarApplication
|
||||||
|
v-for="application in applications"
|
||||||
|
:key="application.id"
|
||||||
|
:application="application"
|
||||||
|
></SidebarApplication>
|
||||||
|
</ul>
|
||||||
|
<a
|
||||||
|
ref="createApplicationContextLink"
|
||||||
|
class="sidebar-new"
|
||||||
|
@click="
|
||||||
|
$refs.createApplicationContext.toggle(
|
||||||
|
$refs.createApplicationContextLink
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
Create new
|
||||||
|
</a>
|
||||||
|
<CreateApplicationContext
|
||||||
|
ref="createApplicationContext"
|
||||||
|
></CreateApplicationContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters, mapState } from 'vuex'
|
||||||
|
|
||||||
|
import SidebarApplication from '@/components/sidebar/SidebarApplication'
|
||||||
|
import CreateApplicationContext from '@/components/sidebar/CreateApplicationContext'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Sidebar',
|
||||||
|
components: {
|
||||||
|
CreateApplicationContext,
|
||||||
|
SidebarApplication
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
applications: state => state.application.items,
|
||||||
|
selectedGroup: state => state.group.selected
|
||||||
|
}),
|
||||||
|
...mapGetters({
|
||||||
|
isLoading: 'application/isLoading',
|
||||||
|
hasSelectedGroup: 'group/hasSelected'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
131
web-frontend/components/sidebar/SidebarApplication.vue
Normal file
131
web-frontend/components/sidebar/SidebarApplication.vue
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="tree-item"
|
||||||
|
:class="{
|
||||||
|
active: application._.selected,
|
||||||
|
'tree-item-loading': application._.loading
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="tree-action">
|
||||||
|
<a class="tree-link" @click="selectApplication(application)">
|
||||||
|
<i
|
||||||
|
class="tree-type fas"
|
||||||
|
:class="'fa-' + application._.type.iconClass"
|
||||||
|
></i>
|
||||||
|
<Editable
|
||||||
|
ref="rename"
|
||||||
|
:value="application.name"
|
||||||
|
@change="renameApplication(application, $event)"
|
||||||
|
></Editable>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
ref="contextLink"
|
||||||
|
class="tree-options"
|
||||||
|
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</a>
|
||||||
|
<Context ref="context">
|
||||||
|
<div class="context-menu-title">{{ application.name }}</div>
|
||||||
|
<ul class="context-menu">
|
||||||
|
<li>
|
||||||
|
<a @click="enableRename()">
|
||||||
|
<i class="context-menu-icon fas fa-fw fa-pen"></i>
|
||||||
|
Rename {{ application._.type.name | lowercase }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a @click="deleteApplication(application)">
|
||||||
|
<i class="context-menu-icon fas fa-fw fa-trash"></i>
|
||||||
|
Delete {{ application._.type.name | lowercase }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Context>
|
||||||
|
</div>
|
||||||
|
<template
|
||||||
|
v-if="
|
||||||
|
application._.selected && application._.type.hasSelectedSidebarComponent
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="getSelectedApplicationComponent(application)"
|
||||||
|
:application="application"
|
||||||
|
></component>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'SidebarApplication',
|
||||||
|
props: {
|
||||||
|
application: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setLoading(application, value) {
|
||||||
|
this.$store.dispatch('application/setItemLoading', {
|
||||||
|
application,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
},
|
||||||
|
enableRename() {
|
||||||
|
this.$refs.context.hide()
|
||||||
|
this.$refs.rename.edit()
|
||||||
|
},
|
||||||
|
renameApplication(application, event) {
|
||||||
|
this.setLoading(application, true)
|
||||||
|
|
||||||
|
this.$store
|
||||||
|
.dispatch('application/update', {
|
||||||
|
application,
|
||||||
|
values: {
|
||||||
|
name: event.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// If something is going wrong we will reset the original value
|
||||||
|
this.$refs.rename.set(event.oldValue)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.setLoading(application, false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectApplication(application) {
|
||||||
|
this.setLoading(application, true)
|
||||||
|
|
||||||
|
this.$nuxt.$router.push(
|
||||||
|
{
|
||||||
|
name: application._.type.routeName,
|
||||||
|
params: {
|
||||||
|
id: application.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.setLoading(application, false)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.setLoading(application, false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deleteApplication(application) {
|
||||||
|
this.$refs.context.hide()
|
||||||
|
this.setLoading(application, true)
|
||||||
|
|
||||||
|
this.$store.dispatch('application/delete', application).then(() => {
|
||||||
|
this.setLoading(application, false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getSelectedApplicationComponent(application) {
|
||||||
|
const type = this.$store.getters['application/getApplicationByType'](
|
||||||
|
application.type
|
||||||
|
)
|
||||||
|
return type.getSelectedSidebarComponent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,8 +1,8 @@
|
||||||
export default {
|
export default {
|
||||||
mode: 'universal',
|
mode: 'universal',
|
||||||
|
|
||||||
/*
|
/**
|
||||||
** Headers of the page
|
* Headers of the page
|
||||||
*/
|
*/
|
||||||
head: {
|
head: {
|
||||||
title: 'Baserow',
|
title: 'Baserow',
|
||||||
|
@ -12,18 +12,18 @@ export default {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/**
|
||||||
** Customize the progress-bar color
|
* Customize the progress-bar color
|
||||||
*/
|
*/
|
||||||
loading: { color: '#fff' },
|
loading: { color: '#fff' },
|
||||||
|
|
||||||
/*
|
/**
|
||||||
** Global CSS
|
* Global CSS
|
||||||
*/
|
*/
|
||||||
css: ['@/assets/scss/default.scss'],
|
css: ['@/assets/scss/default.scss'],
|
||||||
|
|
||||||
/*
|
/**
|
||||||
** Plugins to load before mounting the App
|
* Plugins to load before mounting the App
|
||||||
*/
|
*/
|
||||||
plugins: [
|
plugins: [
|
||||||
{ src: '@/plugins/global.js' },
|
{ src: '@/plugins/global.js' },
|
||||||
|
@ -32,13 +32,17 @@ export default {
|
||||||
{ src: '@/plugins/vuelidate.js' }
|
{ src: '@/plugins/vuelidate.js' }
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/**
|
||||||
** Nuxt.js modules
|
* Nuxt.js modules
|
||||||
*/
|
*/
|
||||||
modules: ['@nuxtjs/axios', 'cookie-universal-nuxt'],
|
modules: [
|
||||||
|
'@nuxtjs/axios',
|
||||||
|
'cookie-universal-nuxt',
|
||||||
|
'@/modules/database/module.js'
|
||||||
|
],
|
||||||
|
|
||||||
router: {
|
router: {
|
||||||
middleware: 'authentication'
|
middleware: ['authentication', 'group']
|
||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
|
|
92
web-frontend/core/applications.js
Normal file
92
web-frontend/core/applications.js
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import ApplicationForm from '@/components/sidebar/ApplicationForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application base class that can be extended when creating a plugin for
|
||||||
|
* the frontend.
|
||||||
|
*/
|
||||||
|
export class Application {
|
||||||
|
/**
|
||||||
|
* Must return a string with the unique name, this must be the same as the
|
||||||
|
* type used in the backend.
|
||||||
|
*/
|
||||||
|
getType() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font awesome 5 icon name that is used as convenience for the user to
|
||||||
|
* recognize certain application types. If you for example want the database
|
||||||
|
* icon, you must return 'database' here. This will result in the classname
|
||||||
|
* 'fas fa-database'.
|
||||||
|
*/
|
||||||
|
getIconClass() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A human readable name of the application.
|
||||||
|
*/
|
||||||
|
getName() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must return the route name where the application can navigate to when the
|
||||||
|
* application is selected.
|
||||||
|
*/
|
||||||
|
getRouteName() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form component that will be rendered when creating a new instance of
|
||||||
|
* this application. By default the ApplicationForm component is returned, but
|
||||||
|
* this only contains a name field. If custom fields are required upon
|
||||||
|
* creating they can be added with this component.
|
||||||
|
*/
|
||||||
|
getApplicationFormComponent() {
|
||||||
|
return ApplicationForm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sidebar component that will be rendered when an application instance
|
||||||
|
* is selected. By default no component will rendered. This could be used for
|
||||||
|
* example to render a list of tables that belong to a database.
|
||||||
|
*/
|
||||||
|
getSelectedSidebarComponent() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.type = this.getType()
|
||||||
|
this.iconClass = this.getIconClass()
|
||||||
|
this.name = this.getName()
|
||||||
|
this.routeName = this.getRouteName()
|
||||||
|
|
||||||
|
if (this.type === null) {
|
||||||
|
throw new Error('The type name of an application must be set.')
|
||||||
|
}
|
||||||
|
if (this.iconClass === null) {
|
||||||
|
throw new Error('The icon class of an application must be set.')
|
||||||
|
}
|
||||||
|
if (this.name === null) {
|
||||||
|
throw new Error('The name of an application must be set.')
|
||||||
|
}
|
||||||
|
if (this.routeName === null) {
|
||||||
|
throw new Error('The route name of an application must be set.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
serialize() {
|
||||||
|
return {
|
||||||
|
type: this.type,
|
||||||
|
iconClass: this.iconClass,
|
||||||
|
name: this.name,
|
||||||
|
routeName: this.routeName,
|
||||||
|
hasSelectedSidebarComponent: this.getSelectedSidebarComponent() !== null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
web-frontend/filters/lowercase.js
Normal file
9
web-frontend/filters/lowercase.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Converts a string to the same string, but with lowercase characters.
|
||||||
|
*/
|
||||||
|
export default function(value) {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return value.toString().toLowerCase()
|
||||||
|
}
|
|
@ -57,6 +57,7 @@
|
||||||
<div class="sidebar-title">
|
<div class="sidebar-title">
|
||||||
<img src="@/static/img/logo.svg" alt="" />
|
<img src="@/static/img/logo.svg" alt="" />
|
||||||
</div>
|
</div>
|
||||||
|
<Sidebar></Sidebar>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
@ -78,12 +79,14 @@ import { mapActions, mapGetters } from 'vuex'
|
||||||
|
|
||||||
import Notifications from '@/components/notifications/Notifications'
|
import Notifications from '@/components/notifications/Notifications'
|
||||||
import GroupsContext from '@/components/group/GroupsContext'
|
import GroupsContext from '@/components/group/GroupsContext'
|
||||||
|
import Sidebar from '@/components/sidebar/Sidebar'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
middleware: 'authenticated',
|
middleware: 'authenticated',
|
||||||
components: {
|
components: {
|
||||||
GroupsContext,
|
GroupsContext,
|
||||||
Notifications
|
Notifications,
|
||||||
|
Sidebar
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
|
|
33
web-frontend/middleware/group.js
Normal file
33
web-frontend/middleware/group.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { getGroupCookie, unsetGroupCookie } from '@/utils/group'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This middleware checks if there is a saved group id in the cookies. If set
|
||||||
|
* it will fetch the groups, and related application of that group.
|
||||||
|
*/
|
||||||
|
export default function({ store, req, app }) {
|
||||||
|
// If nuxt generate, pass this middleware
|
||||||
|
if (process.server && !req) return
|
||||||
|
|
||||||
|
// Get the selected group id
|
||||||
|
const groupId = getGroupCookie(app.$cookies)
|
||||||
|
|
||||||
|
// If a group id cookie is set, the user is authenticated and a selected group
|
||||||
|
// is not already set then we will fetch the groups and select that group.
|
||||||
|
if (
|
||||||
|
groupId &&
|
||||||
|
store.getters['auth/isAuthenticated'] &&
|
||||||
|
!store.getters['group/hasSelected']
|
||||||
|
) {
|
||||||
|
return store
|
||||||
|
.dispatch('group/fetchAll')
|
||||||
|
.catch(() => {
|
||||||
|
unsetGroupCookie(app.$cookies)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const group = store.getters['group/get'](groupId)
|
||||||
|
if (group) {
|
||||||
|
return store.dispatch('group/select', group)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
30
web-frontend/mixins/application.js
Normal file
30
web-frontend/mixins/application.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { notify404 } from '@/utils/error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This mixin can be used in combination with the component where the
|
||||||
|
* application routes to when selected. It will make sure that the application
|
||||||
|
* preSelect action is called so that the all the depending information is
|
||||||
|
* loaded. If something goes wrong while loading this information it will show a
|
||||||
|
* standard error.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch('application/preSelect', this.id).catch(error => {
|
||||||
|
notify404(
|
||||||
|
this.$store.dispatch,
|
||||||
|
error,
|
||||||
|
'Application not found.',
|
||||||
|
"The application with the provided id doesn't exist or you " +
|
||||||
|
"don't have access to it."
|
||||||
|
)
|
||||||
|
|
||||||
|
this.$nuxt.$router.push({ name: 'app' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,13 +13,13 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggle(...args) {
|
toggle(...args) {
|
||||||
this.getRootModal().toggle(...args)
|
this.getRootContext().toggle(...args)
|
||||||
},
|
},
|
||||||
show(...args) {
|
show(...args) {
|
||||||
this.getRootModal().show(...args)
|
this.getRootContext().show(...args)
|
||||||
},
|
},
|
||||||
hide(...args) {
|
hide(...args) {
|
||||||
this.getRootModal().hide(...args)
|
this.getRootContext().hide(...args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,69 @@
|
||||||
export default {
|
export default {
|
||||||
/**
|
data() {
|
||||||
* Because we don't want the parent context to close when a user clicks 'outside' that
|
return {
|
||||||
* element and in the child element we need to register the child with their parent to
|
moveToBody: {
|
||||||
* prevent this.
|
children: [],
|
||||||
*/
|
hasMoved: false,
|
||||||
mounted() {
|
movedEventHandlers: []
|
||||||
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
|
* Because we want the to be able to stack nested elements that are moved to
|
||||||
* and that the element is removed from the body.
|
* the body, they have to be placed at the correct position. If it has no
|
||||||
|
* parent is must be moved to the top of the body, but if there is a parent it
|
||||||
|
* must be directly under that so it will always display on over of that
|
||||||
|
* component.
|
||||||
|
*/
|
||||||
|
mounted() {
|
||||||
|
let parent = this.$parent
|
||||||
|
let first = null
|
||||||
|
|
||||||
|
// Loop over the parent components to register himself als child in order
|
||||||
|
// to prevent closing when clicking in a child. We also check which parent
|
||||||
|
// is first so can correctly move the element.
|
||||||
|
while (parent !== undefined) {
|
||||||
|
if (parent.hasOwnProperty('moveToBody')) {
|
||||||
|
parent.registerMoveToBodyChild(this)
|
||||||
|
if (first === null) {
|
||||||
|
first = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent = parent.$parent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
// If there is a parent where we can register we want to position the
|
||||||
|
// element directly after that one so it will always be positioned over
|
||||||
|
// the parent when opened.
|
||||||
|
const handler = () => {
|
||||||
|
// Some times we have to wait for elements to render like with v-if.
|
||||||
|
this.$nextTick(() => {
|
||||||
|
first.$el.parentNode.insertBefore(this.$el, first.$el.nextSibling)
|
||||||
|
this.fireMovedToBodyHandlers()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the element has already moved to the body we can directly move it to
|
||||||
|
// the correct position. If not we have to wait until it will move.
|
||||||
|
if (first.moveToBody.hasMoved) {
|
||||||
|
handler()
|
||||||
|
} else {
|
||||||
|
first.addMovedToBodyHandler(handler)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Because there is no parent we can directly move the component to the
|
||||||
|
// top of the body so it will be positioned over any other element.
|
||||||
|
const body = document.body
|
||||||
|
body.insertBefore(this.$el, body.firstChild)
|
||||||
|
this.fireMovedToBodyHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.moveToBody.hasMoved = true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 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() {
|
destroyed() {
|
||||||
this.hide()
|
this.hide()
|
||||||
|
@ -28,5 +71,26 @@ export default {
|
||||||
if (this.$el.parentNode) {
|
if (this.$el.parentNode) {
|
||||||
this.$el.parentNode.removeChild(this.$el)
|
this.$el.parentNode.removeChild(this.$el)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Event handlers when the element has moved to the body can be registered
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
addMovedToBodyHandler(handler) {
|
||||||
|
this.moveToBody.movedEventHandlers.push(handler)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
fireMovedToBodyHandlers() {
|
||||||
|
this.moveToBody.movedEventHandlers.forEach(handler => handler())
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
registerMoveToBodyChild(child) {
|
||||||
|
this.moveToBody.children.push(child)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
24
web-frontend/modules/database/application.js
Normal file
24
web-frontend/modules/database/application.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { Application } from '@/core/applications'
|
||||||
|
import Sidebar from '@/modules/database/components/Sidebar'
|
||||||
|
|
||||||
|
export class DatabaseApplication extends Application {
|
||||||
|
getType() {
|
||||||
|
return 'database'
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconClass() {
|
||||||
|
return 'database'
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return 'Database'
|
||||||
|
}
|
||||||
|
|
||||||
|
getRouteName() {
|
||||||
|
return 'application-database'
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedSidebarComponent() {
|
||||||
|
return Sidebar
|
||||||
|
}
|
||||||
|
}
|
38
web-frontend/modules/database/components/Sidebar.vue
Normal file
38
web-frontend/modules/database/components/Sidebar.vue
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ul class="tree-subs">
|
||||||
|
<li class="tree-sub active">
|
||||||
|
<a href="#" class="tree-sub-link">@TODO</a>
|
||||||
|
<a
|
||||||
|
class="tree-options"
|
||||||
|
@click="
|
||||||
|
$refs.context.toggle($event.currentTarget, 'bottom', 'right', 0)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</a>
|
||||||
|
<Context ref="context">
|
||||||
|
<div class="context-menu-title">@TODO</div>
|
||||||
|
<ul class="context-menu">
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<i class="context-menu-icon fas fa-fw fa-pen"></i>
|
||||||
|
Rename
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<i class="context-menu-icon fas fa-fw fa-trash"></i>
|
||||||
|
Delete
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Context>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<a href="#" class="tree-sub-add">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
Create table
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
15
web-frontend/modules/database/module.js
Normal file
15
web-frontend/modules/database/module.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import { databaseRoutes } from './routes'
|
||||||
|
|
||||||
|
export default function DatabaseModule(options) {
|
||||||
|
// Add the plugin to register the database application.
|
||||||
|
this.addPlugin({
|
||||||
|
src: path.resolve(__dirname, 'plugin.js'),
|
||||||
|
filename: 'plugin.js'
|
||||||
|
})
|
||||||
|
|
||||||
|
this.extendRoutes(routes => {
|
||||||
|
routes.push(...databaseRoutes)
|
||||||
|
})
|
||||||
|
}
|
29
web-frontend/modules/database/pages/Database.vue
Normal file
29
web-frontend/modules/database/pages/Database.vue
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<header class="layout-col-3-1 header">
|
||||||
|
<ul class="header-filter">
|
||||||
|
<li class="header-filter-item"> </li>
|
||||||
|
</ul>
|
||||||
|
<ul class="header-info">
|
||||||
|
<li>{{ selectedApplication.name }}</li>
|
||||||
|
<li>@TODO table name</li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
|
import application from '@/mixins/application'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
layout: 'app',
|
||||||
|
mixins: [application],
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
selectedApplication: state => state.application.selected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
5
web-frontend/modules/database/plugin.js
Normal file
5
web-frontend/modules/database/plugin.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { DatabaseApplication } from '@/modules/database/application'
|
||||||
|
|
||||||
|
export default ({ store }) => {
|
||||||
|
store.dispatch('application/register', new DatabaseApplication())
|
||||||
|
}
|
14
web-frontend/modules/database/routes.js
Normal file
14
web-frontend/modules/database/routes.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export const databaseRoutes = [
|
||||||
|
{
|
||||||
|
name: 'application-database',
|
||||||
|
path: '/database/:id',
|
||||||
|
component: path.resolve(__dirname, 'pages/Database.vue'),
|
||||||
|
props(route) {
|
||||||
|
const props = { ...route.params }
|
||||||
|
props.id = parseInt(props.id)
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
|
@ -3,6 +3,17 @@
|
||||||
<h1>Welcome {{ user }}</h1>
|
<h1>Welcome {{ user }}</h1>
|
||||||
<p>
|
<p>
|
||||||
{{ groups }}
|
{{ groups }}
|
||||||
|
<br /><br />
|
||||||
|
{{ selectedGroup }}
|
||||||
|
<br /><br />
|
||||||
|
{{ applications }}
|
||||||
|
<br /><br />
|
||||||
|
{{ groupApplications }}
|
||||||
|
<br /><br />
|
||||||
|
<nuxt-link :to="{ name: 'application-database', params: { id: 1 } }">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
App
|
||||||
|
</nuxt-link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -15,7 +26,10 @@ export default {
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
user: state => state.auth.user,
|
user: state => state.auth.user,
|
||||||
groups: state => state.group.items
|
groups: state => state.group.items,
|
||||||
|
selectedGroup: state => state.group.selected,
|
||||||
|
applications: state => state.application.applications,
|
||||||
|
groupApplications: state => state.application.items
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,10 @@ import Context from '@/components/Context'
|
||||||
import Modal from '@/components/Modal'
|
import Modal from '@/components/Modal'
|
||||||
import Editable from '@/components/Editable'
|
import Editable from '@/components/Editable'
|
||||||
|
|
||||||
|
import lowercase from '@/filters/lowercase'
|
||||||
|
|
||||||
Vue.component('Context', Context)
|
Vue.component('Context', Context)
|
||||||
Vue.component('Modal', Modal)
|
Vue.component('Modal', Modal)
|
||||||
Vue.component('Editable', Editable)
|
Vue.component('Editable', Editable)
|
||||||
|
|
||||||
|
Vue.filter('lowercase', lowercase)
|
||||||
|
|
19
web-frontend/services/application.js
Normal file
19
web-frontend/services/application.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { client } from './client'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetchAll(groupId) {
|
||||||
|
return client.get(`/applications/group/${groupId}/`)
|
||||||
|
},
|
||||||
|
create(groupId, values) {
|
||||||
|
return client.post(`/applications/group/${groupId}/`, values)
|
||||||
|
},
|
||||||
|
get(applicationId) {
|
||||||
|
return client.get(`/applications/${applicationId}/`)
|
||||||
|
},
|
||||||
|
update(applicationId, values) {
|
||||||
|
return client.patch(`/applications/${applicationId}/`, values)
|
||||||
|
},
|
||||||
|
delete(applicationId) {
|
||||||
|
return client.delete(`/applications/${applicationId}/`)
|
||||||
|
}
|
||||||
|
}
|
275
web-frontend/store/application.js
Normal file
275
web-frontend/store/application.js
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
import { Application } from '@/core/applications'
|
||||||
|
import ApplicationService from '@/services/application'
|
||||||
|
import { notify404, notifyError } from '@/utils/error'
|
||||||
|
|
||||||
|
function populateApplication(application, getters) {
|
||||||
|
const type = getters.getApplicationByType(application.type)
|
||||||
|
|
||||||
|
application._ = {
|
||||||
|
type: type.serialize(),
|
||||||
|
loading: false,
|
||||||
|
selected: false
|
||||||
|
}
|
||||||
|
return application
|
||||||
|
}
|
||||||
|
|
||||||
|
export const state = () => ({
|
||||||
|
applications: {},
|
||||||
|
loading: false,
|
||||||
|
items: [],
|
||||||
|
selected: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const mutations = {
|
||||||
|
REGISTER(state, application) {
|
||||||
|
state.applications[application.type] = application
|
||||||
|
},
|
||||||
|
SET_ITEMS(state, applications) {
|
||||||
|
state.items = applications
|
||||||
|
},
|
||||||
|
SET_LOADING(state, value) {
|
||||||
|
state.loading = value
|
||||||
|
},
|
||||||
|
SET_ITEM_LOADING(state, { application, value }) {
|
||||||
|
application._.loading = value
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
SET_SELECTED(state, group) {
|
||||||
|
Object.values(state.items).forEach(item => {
|
||||||
|
item._.selected = false
|
||||||
|
})
|
||||||
|
group._.selected = true
|
||||||
|
state.selected = group
|
||||||
|
},
|
||||||
|
UNSELECT(state) {
|
||||||
|
Object.values(state.items).forEach(item => {
|
||||||
|
item._.selected = false
|
||||||
|
})
|
||||||
|
state.selected = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
/**
|
||||||
|
* Register a new application within the registry. The is commonly used when
|
||||||
|
* creating an extension.
|
||||||
|
*/
|
||||||
|
register({ commit }, application) {
|
||||||
|
if (!(application instanceof Application)) {
|
||||||
|
throw Error('The application must be an instance of Application.')
|
||||||
|
}
|
||||||
|
|
||||||
|
commit('REGISTER', application)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Changes the loading state of a specific item.
|
||||||
|
*/
|
||||||
|
setItemLoading({ commit }, { application, value }) {
|
||||||
|
commit('SET_ITEM_LOADING', { application, value })
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Fetches all the applications of a given group. The is mostly called when
|
||||||
|
* the user selects a different group.
|
||||||
|
*/
|
||||||
|
fetchAll({ commit, getters, dispatch }, group) {
|
||||||
|
commit('SET_LOADING', true)
|
||||||
|
|
||||||
|
return ApplicationService.fetchAll(group.id)
|
||||||
|
.then(({ data }) => {
|
||||||
|
data.forEach((part, index, d) => {
|
||||||
|
populateApplication(data[index], getters)
|
||||||
|
})
|
||||||
|
commit('SET_ITEMS', data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
commit('SET_ITEMS', [])
|
||||||
|
|
||||||
|
notify404(
|
||||||
|
dispatch,
|
||||||
|
error,
|
||||||
|
'Unable to fetch applications',
|
||||||
|
"You're unable to fetch the application of this group. " +
|
||||||
|
"This could be because you're not part of the group."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
commit('SET_LOADING', false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Clears all the currently selected applications, this could be called when
|
||||||
|
* the group is deleted of when the user logs off.
|
||||||
|
*/
|
||||||
|
clearAll({ commit }) {
|
||||||
|
commit('SET_ITEMS', [])
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Creates a new application with the given type and values for the currently
|
||||||
|
* selected group.
|
||||||
|
*/
|
||||||
|
create({ commit, getters, rootGetters, dispatch }, { type, values }) {
|
||||||
|
if (values.hasOwnProperty('type')) {
|
||||||
|
throw new Error(
|
||||||
|
'The key "type" is a reserved, but is already set on the ' +
|
||||||
|
'values when creating a new application.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getters.applicationTypeExists(type)) {
|
||||||
|
throw new Error(`An application with type "${type}" doesn't exist.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
values.type = type
|
||||||
|
return ApplicationService.create(rootGetters['group/selectedId'], values)
|
||||||
|
.then(({ data }) => {
|
||||||
|
populateApplication(data, getters)
|
||||||
|
commit('ADD_ITEM', data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
notify404(
|
||||||
|
dispatch,
|
||||||
|
error,
|
||||||
|
'Could not create application',
|
||||||
|
"You're unable to create a new application for the selected " +
|
||||||
|
"group. This could be because you're not part of the group."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Updates the values of an existing application.
|
||||||
|
*/
|
||||||
|
update({ commit, dispatch }, { application, values }) {
|
||||||
|
return ApplicationService.update(application.id, values)
|
||||||
|
.then(({ data }) => {
|
||||||
|
commit('UPDATE_ITEM', data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
notifyError(
|
||||||
|
dispatch,
|
||||||
|
error,
|
||||||
|
'ERROR_USER_NOT_IN_GROUP',
|
||||||
|
'Rename not allowed',
|
||||||
|
"You're not allowed to rename the application because you're " +
|
||||||
|
'not part of the group where the application is in.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Deletes an existing application.
|
||||||
|
*/
|
||||||
|
delete({ commit, dispatch }, application) {
|
||||||
|
return ApplicationService.delete(application.id)
|
||||||
|
.then(() => {
|
||||||
|
commit('DELETE_ITEM', application.id)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
notifyError(
|
||||||
|
dispatch,
|
||||||
|
error,
|
||||||
|
'ERROR_USER_NOT_IN_GROUP',
|
||||||
|
'Delete not allowed',
|
||||||
|
"You're not allowed to rename the application because you're" +
|
||||||
|
' not part of the group where the application is in.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Select an application.
|
||||||
|
*/
|
||||||
|
select({ commit }, application) {
|
||||||
|
commit('SET_SELECTED', application)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Select an application by a given application id.
|
||||||
|
*/
|
||||||
|
selectById({ dispatch, getters }, id) {
|
||||||
|
const application = getters.get(id)
|
||||||
|
if (application === undefined) {
|
||||||
|
throw new Error(`Application with id ${id} is not found.`)
|
||||||
|
}
|
||||||
|
return dispatch('select', application)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Unselect the
|
||||||
|
*/
|
||||||
|
unselect({ commit }) {
|
||||||
|
commit('UNSELECT', {})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The preSelect action will eventually select an application, but it will
|
||||||
|
* first check which information still needs to be loaded. For example if
|
||||||
|
* no group or not the group where the application is in loaded it will then
|
||||||
|
* first fetch that group and related application so that the sidebar is up
|
||||||
|
* to date. In short it will make sure that the depending state of the given
|
||||||
|
* application will be there.
|
||||||
|
*/
|
||||||
|
preSelect({ dispatch, getters, rootGetters }, id) {
|
||||||
|
// First we will check if the application is already in the items.
|
||||||
|
const application = getters.get(id)
|
||||||
|
|
||||||
|
// If the application is already selected we don't have to do anything.
|
||||||
|
if (application !== undefined && application._.selected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function will select a group by its id which will then automatically
|
||||||
|
// fetch the applications related to that group. When done it will select
|
||||||
|
// the provided application id.
|
||||||
|
const selectGroupAndApplication = (groupId, applicationId) => {
|
||||||
|
return dispatch('group/selectById', groupId, {
|
||||||
|
root: true
|
||||||
|
}).then(() => {
|
||||||
|
return dispatch('selectById', applicationId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (application !== undefined) {
|
||||||
|
// If the application is already in the selected groups, which means that
|
||||||
|
// the groups and applications are already loaded, we can just select that
|
||||||
|
// application.
|
||||||
|
dispatch('select', application)
|
||||||
|
} else {
|
||||||
|
// The application is not in the selected group so we need to figure out
|
||||||
|
// in which he is by fetching the application.
|
||||||
|
return ApplicationService.get(id).then(data => {
|
||||||
|
if (!rootGetters['group/isLoaded']) {
|
||||||
|
// If the groups are not already loaded we need to load them first.
|
||||||
|
return dispatch('group/fetchAll', {}, { root: true }).then(() => {
|
||||||
|
return selectGroupAndApplication(data.data.group.id, id)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// The groups are already loaded so we
|
||||||
|
return selectGroupAndApplication(data.data.group.id, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getters = {
|
||||||
|
isLoading(state) {
|
||||||
|
return state.loading
|
||||||
|
},
|
||||||
|
get: state => id => {
|
||||||
|
return state.items.find(item => item.id === id)
|
||||||
|
},
|
||||||
|
applicationTypeExists: state => type => {
|
||||||
|
return state.applications.hasOwnProperty(type)
|
||||||
|
},
|
||||||
|
getApplicationByType: state => type => {
|
||||||
|
if (!state.applications.hasOwnProperty(type)) {
|
||||||
|
throw new Error(`An application with type "${type}" doesn't exist.`)
|
||||||
|
}
|
||||||
|
return state.applications[type]
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import jwtDecode from 'jwt-decode'
|
||||||
|
|
||||||
import AuthService from '@/services/auth'
|
import AuthService from '@/services/auth'
|
||||||
import { setToken, unsetToken } from '@/utils/auth'
|
import { setToken, unsetToken } from '@/utils/auth'
|
||||||
|
import { unsetGroupCookie } from '@/utils/group'
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
|
@ -53,9 +54,12 @@ export const actions = {
|
||||||
* Logs off the user by removing the token as a cookie and clearing the user
|
* Logs off the user by removing the token as a cookie and clearing the user
|
||||||
* data.
|
* data.
|
||||||
*/
|
*/
|
||||||
logoff({ commit }) {
|
logoff({ commit, dispatch }) {
|
||||||
unsetToken(this.app.$cookies)
|
unsetToken(this.app.$cookies)
|
||||||
|
unsetGroupCookie(this.app.$cookies)
|
||||||
commit('CLEAR_USER_DATA')
|
commit('CLEAR_USER_DATA')
|
||||||
|
dispatch('group/clearAll', {}, { root: true })
|
||||||
|
dispatch('group/unselect', {}, { root: true })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Refresh the existing token. If successful commit the new token and start a
|
* Refresh the existing token. If successful commit the new token and start a
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
// import { set } from 'vue'
|
|
||||||
|
|
||||||
import GroupService from '@/services/group'
|
import GroupService from '@/services/group'
|
||||||
|
import { notify404 } from '@/utils/error'
|
||||||
|
import { setGroupCookie, unsetGroupCookie } from '@/utils/group'
|
||||||
|
|
||||||
|
function populateGroup(group) {
|
||||||
|
group._ = { loading: false, selected: false }
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
loaded: false,
|
loaded: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
items: []
|
items: [],
|
||||||
|
selected: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
@ -16,9 +22,20 @@ export const mutations = {
|
||||||
state.loading = loading
|
state.loading = loading
|
||||||
},
|
},
|
||||||
SET_ITEMS(state, items) {
|
SET_ITEMS(state, items) {
|
||||||
state.items = items
|
// Set some default values that we might need later.
|
||||||
|
state.items = items.map(item => {
|
||||||
|
item = populateGroup(item)
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
},
|
||||||
|
SET_ITEM_LOADING(state, { group, value }) {
|
||||||
|
if (!group.hasOwnProperty('_')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group._.loading = value
|
||||||
},
|
},
|
||||||
ADD_ITEM(state, item) {
|
ADD_ITEM(state, item) {
|
||||||
|
item = populateGroup(item)
|
||||||
state.items.push(item)
|
state.items.push(item)
|
||||||
},
|
},
|
||||||
UPDATE_ITEM(state, values) {
|
UPDATE_ITEM(state, values) {
|
||||||
|
@ -28,15 +45,48 @@ export const mutations = {
|
||||||
DELETE_ITEM(state, id) {
|
DELETE_ITEM(state, id) {
|
||||||
const index = state.items.findIndex(item => item.id === id)
|
const index = state.items.findIndex(item => item.id === id)
|
||||||
state.items.splice(index, 1)
|
state.items.splice(index, 1)
|
||||||
|
},
|
||||||
|
SET_SELECTED(state, group) {
|
||||||
|
Object.values(state.items).forEach(item => {
|
||||||
|
item._.selected = false
|
||||||
|
})
|
||||||
|
group._.selected = true
|
||||||
|
state.selected = group
|
||||||
|
},
|
||||||
|
UNSELECT(state) {
|
||||||
|
Object.values(state.items).forEach(item => {
|
||||||
|
item._.selected = false
|
||||||
|
})
|
||||||
|
state.selected = {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
/**
|
||||||
|
* If not already loading or loaded it will trigger the fetchAll action which
|
||||||
|
* will load all the groups for the user.
|
||||||
|
*/
|
||||||
loadAll({ state, dispatch }) {
|
loadAll({ state, dispatch }) {
|
||||||
if (!state.loaded && !state.loading) {
|
if (!state.loaded && !state.loading) {
|
||||||
dispatch('fetchAll')
|
dispatch('fetchAll')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Clears all the selected groups. Can be used when logging off.
|
||||||
|
*/
|
||||||
|
clearAll({ commit }) {
|
||||||
|
commit('SET_ITEMS', [])
|
||||||
|
commit('SET_LOADED', false)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Changes the loading state of a specific group.
|
||||||
|
*/
|
||||||
|
setItemLoading({ commit }, { group, value }) {
|
||||||
|
commit('SET_ITEM_LOADING', { group, value })
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Fetches all the groups of an authenticated user.
|
||||||
|
*/
|
||||||
fetchAll({ commit }) {
|
fetchAll({ commit }) {
|
||||||
commit('SET_LOADING', true)
|
commit('SET_LOADING', true)
|
||||||
|
|
||||||
|
@ -52,21 +102,79 @@ export const actions = {
|
||||||
commit('SET_LOADING', false)
|
commit('SET_LOADING', false)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Creates a new group with the given values.
|
||||||
|
*/
|
||||||
create({ commit }, values) {
|
create({ commit }, values) {
|
||||||
return GroupService.create(values).then(({ data }) => {
|
return GroupService.create(values).then(({ data }) => {
|
||||||
commit('ADD_ITEM', data)
|
commit('ADD_ITEM', data)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
update({ commit }, { id, values }) {
|
/**
|
||||||
return GroupService.update(id, values).then(({ data }) => {
|
* Updates the values of the group with the provided id.
|
||||||
commit('UPDATE_ITEM', data)
|
*/
|
||||||
})
|
update({ commit, dispatch }, { group, values }) {
|
||||||
|
return GroupService.update(group.id, values)
|
||||||
|
.then(({ data }) => {
|
||||||
|
commit('UPDATE_ITEM', data)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
notify404(
|
||||||
|
dispatch,
|
||||||
|
error,
|
||||||
|
'Unable to rename',
|
||||||
|
"You're unable to rename the group. This could be because " +
|
||||||
|
"you're not part of the group."
|
||||||
|
)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
delete({ commit }, id) {
|
/**
|
||||||
return GroupService.delete(id).then(() => {
|
* Deletes an existing group with the provided id.
|
||||||
console.log(id)
|
*/
|
||||||
commit('DELETE_ITEM', id)
|
delete({ commit, dispatch }, group) {
|
||||||
})
|
return GroupService.delete(group.id)
|
||||||
|
.then(() => {
|
||||||
|
if (group._.selected) {
|
||||||
|
dispatch('unselect', group)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit('DELETE_ITEM', group.id)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
notify404(
|
||||||
|
dispatch,
|
||||||
|
error,
|
||||||
|
'Unable to delete',
|
||||||
|
"You're unable to delete the group. This could be because " +
|
||||||
|
"you're not part of the group."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Select a group and fetch all the applications related to that group.
|
||||||
|
*/
|
||||||
|
select({ commit, dispatch }, group) {
|
||||||
|
commit('SET_SELECTED', group)
|
||||||
|
setGroupCookie(group.id, this.app.$cookies)
|
||||||
|
return dispatch('application/fetchAll', group, { root: true })
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Select a group by a given group id.
|
||||||
|
*/
|
||||||
|
selectById({ dispatch, getters }, id) {
|
||||||
|
const group = getters.get(id)
|
||||||
|
if (group === undefined) {
|
||||||
|
throw new Error(`Group with id ${id} is not found.`)
|
||||||
|
}
|
||||||
|
return dispatch('select', group)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Unselect a group if selected and clears all the fetched applications.
|
||||||
|
*/
|
||||||
|
unselect({ commit, dispatch, getters }, group) {
|
||||||
|
commit('UNSELECT', {})
|
||||||
|
unsetGroupCookie(this.app.$cookies)
|
||||||
|
return dispatch('application/clearAll', group, { root: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,5 +184,18 @@ export const getters = {
|
||||||
},
|
},
|
||||||
isLoading(state) {
|
isLoading(state) {
|
||||||
return state.loading
|
return state.loading
|
||||||
|
},
|
||||||
|
get: state => id => {
|
||||||
|
return state.items.find(item => item.id === id)
|
||||||
|
},
|
||||||
|
hasSelected(state) {
|
||||||
|
return state.selected.hasOwnProperty('id')
|
||||||
|
},
|
||||||
|
selectedId(state) {
|
||||||
|
if (!state.selected.hasOwnProperty('id')) {
|
||||||
|
throw new Error('There is no selected group.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.selected.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,9 @@ export const mutations = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
/**
|
||||||
|
* Shows a notification message to the user.
|
||||||
|
*/
|
||||||
add({ commit }, { type, title, message }) {
|
add({ commit }, { type, title, message }) {
|
||||||
commit('ADD', {
|
commit('ADD', {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
|
32
web-frontend/utils/error.js
Normal file
32
web-frontend/utils/error.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* Adds a notification error if the error response has 404 status code.
|
||||||
|
*/
|
||||||
|
export function notify404(dispatch, error, title, message) {
|
||||||
|
if (error.response && error.response.status === 404) {
|
||||||
|
dispatch(
|
||||||
|
'notification/error',
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
message: message
|
||||||
|
},
|
||||||
|
{ root: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a notification error if the response error is equal to the provided
|
||||||
|
* error code.
|
||||||
|
*/
|
||||||
|
export function notifyError(dispatch, error, errorCode, title, message) {
|
||||||
|
if (error.responseError === errorCode) {
|
||||||
|
dispatch(
|
||||||
|
'notification/error',
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
message: message
|
||||||
|
},
|
||||||
|
{ root: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
16
web-frontend/utils/group.js
Normal file
16
web-frontend/utils/group.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
const cookieGroupName = 'baserow_group_id'
|
||||||
|
|
||||||
|
export const setGroupCookie = (groupId, cookie) => {
|
||||||
|
if (process.SERVER_BUILD) return
|
||||||
|
cookie.set(cookieGroupName, groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unsetGroupCookie = cookie => {
|
||||||
|
if (process.SERVER_BUILD) return
|
||||||
|
cookie.remove(cookieGroupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGroupCookie = cookie => {
|
||||||
|
if (process.SERVER_BUILD) return
|
||||||
|
return cookie.get(cookieGroupName)
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue