diff --git a/backend/src/baserow/api/v0/applications/serializers.py b/backend/src/baserow/api/v0/applications/serializers.py index fe73d1b67..1f992c925 100644 --- a/backend/src/baserow/api/v0/applications/serializers.py +++ b/backend/src/baserow/api/v0/applications/serializers.py @@ -1,15 +1,17 @@ from rest_framework import serializers +from baserow.api.v0.groups.serializers import GroupSerializer from baserow.core.applications import registry from baserow.core.models import Application class ApplicationSerializer(serializers.ModelSerializer): type = serializers.SerializerMethodField() + group = GroupSerializer() class Meta: model = Application - fields = ('id', 'name', 'order', 'type') + fields = ('id', 'name', 'order', 'type', 'group') extra_kwargs = { 'id': { 'read_only': True diff --git a/backend/src/baserow/api/v0/applications/views.py b/backend/src/baserow/api/v0/applications/views.py index f5fd73311..f44a76926 100644 --- a/backend/src/baserow/api/v0/applications/views.py +++ b/backend/src/baserow/api/v0/applications/views.py @@ -56,6 +56,15 @@ class ApplicationView(APIView): permission_classes = (IsAuthenticated,) 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 @validate_body(ApplicationUpdateSerializer) @map_exceptions({ diff --git a/backend/tests/baserow/api/v0/applications/test_application_views.py b/backend/tests/baserow/api/v0/applications/test_application_views.py index 7f0a8272d..e2ee16822 100644 --- a/backend/tests/baserow/api/v0/applications/test_application_views.py +++ b/backend/tests/baserow/api/v0/applications/test_application_views.py @@ -94,6 +94,46 @@ def test_create_application(api_client, data_fixture): 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 def test_update_application(api_client, data_fixture): user, token = data_fixture.create_user_and_token() diff --git a/old-web-frontend/public/grid.html b/old-web-frontend/public/grid.html index 297b16f6d..b00dfea57 100644 --- a/old-web-frontend/public/grid.html +++ b/old-web-frontend/public/grid.html @@ -57,7 +57,7 @@ <i class="fas fa-ellipsis-v"></i> </a> </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-options"> <i class="fas fa-ellipsis-v"></i> @@ -102,7 +102,7 @@ </div> <div class="sidebar-group-title">Group name 1</div> <ul class="tree"> - <li class="tree-item"> + <li class="tree-item tree-item-loading"> <div class="tree-action"> <a href="#" class="tree-link"> <i class="tree-type fas fa-database"></i> @@ -139,7 +139,7 @@ </div> </li> <li class="tree-item active"> - <div class="tree-action"> + <div class="tree-action tree-item-loading"> <a href="#" class="tree-link"> <i class="tree-type fas fa-database"></i> Webshop @@ -169,7 +169,39 @@ </li> </ul> </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"> <a href="#" class="tree-link"> <i class="tree-type fas fa-angle-down"></i> @@ -188,7 +220,7 @@ </a> </div> </li> - <li class="tree-item"> + <li class="tree-item tree-item-loading"> <div class="tree-action"> <a href="#" class="tree-link"> <i class="tree-type fas fa-database"></i> diff --git a/web-frontend/assets/scss/components/_select.scss b/web-frontend/assets/scss/components/_select.scss index bdda6739e..e745f0ac3 100644 --- a/web-frontend/assets/scss/components/_select.scss +++ b/web-frontend/assets/scss/components/_select.scss @@ -63,10 +63,17 @@ background-color: $color-neutral-100; } + &.select-item-loading::before { + content: " "; + + @include loading(14px); + @include absolute(9px, 9px, auto, auto); + } + &.active { background-color: $color-primary-100; - &::after { + &:not(.select-item-loading)::after { @extend .fas; @extend %select-item-size; @@ -82,17 +89,6 @@ display: none; } } - - &.select-item-loading { - background-color: $color-neutral-100; - - &::before { - content: " "; - - @include loading(14px); - @include absolute(9px, 9px, auto, auto); - } - } } .select-item-link { diff --git a/web-frontend/assets/scss/components/_sidebar.scss b/web-frontend/assets/scss/components/_sidebar.scss index 830a9c80a..2945dd05f 100644 --- a/web-frontend/assets/scss/components/_sidebar.scss +++ b/web-frontend/assets/scss/components/_sidebar.scss @@ -50,3 +50,14 @@ font-weight: 700; margin-bottom: 10px; } + +.sidebar-new { + font-size: 13px; + color: $color-neutral-300; + margin-left: 7px; + + &:hover { + color: $color-neutral-500; + text-decoration: none; + } +} diff --git a/web-frontend/assets/scss/components/_tree.scss b/web-frontend/assets/scss/components/_tree.scss index 542f6391d..63d193983 100644 --- a/web-frontend/assets/scss/components/_tree.scss +++ b/web-frontend/assets/scss/components/_tree.scss @@ -1,7 +1,7 @@ .tree { list-style: none; padding: 0; - margin: 0; + margin: 0 0 12px; .tree-item & { padding-left: 8px; @@ -18,6 +18,13 @@ &.active { background-color: $color-primary-100; } + + &.tree-item-loading::after { + content: " "; + + @include loading(14px); + @include absolute(9px, 9px, auto, auto); + } } %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 { display: none; position: absolute; @@ -141,4 +160,8 @@ :hover > & { display: block; } + + .tree-item-loading > .tree-action > & { + display: none; + } } diff --git a/web-frontend/components/Context.vue b/web-frontend/components/Context.vue index 6fb984bba..651efb43b 100644 --- a/web-frontend/components/Context.vue +++ b/web-frontend/components/Context.vue @@ -14,8 +14,7 @@ export default { data() { return { open: false, - opener: null, - children: [] + opener: null } }, methods: { @@ -81,9 +80,9 @@ export default { !isElement(this.opener, event.target) && // If the click was not inside one of the context children of this context // menu. - !this.children.some(component => - isElement(component.$el, event.target) - ) + !this.moveToBody.children.some(child => { + return isElement(child.$el, event.target) + }) ) { this.hide() } @@ -96,6 +95,7 @@ export default { hide() { this.opener = null this.open = false + this.$emit('hidden') document.body.removeEventListener('click', this.$el.clickOutsideEvent) }, @@ -171,13 +171,6 @@ export default { } 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) } } } diff --git a/web-frontend/components/Modal.vue b/web-frontend/components/Modal.vue index 67784f817..05c3d703c 100644 --- a/web-frontend/components/Modal.vue +++ b/web-frontend/components/Modal.vue @@ -1,19 +1,17 @@ <template> - <transition name="fade"> - <div - v-if="open" - ref="modalWrapper" - class="modal-wrapper" - @click="outside($event)" - > - <div class="modal-box"> - <a class="modal-close" @click="hide()"> - <i class="fas fa-times"></i> - </a> - <slot></slot> - </div> + <div + v-if="open" + ref="modalWrapper" + class="modal-wrapper" + @click="outside($event)" + > + <div class="modal-box"> + <a class="modal-close" @click="hide()"> + <i class="fas fa-times"></i> + </a> + <slot></slot> </div> - </transition> + </div> </template> <script> @@ -56,7 +54,16 @@ export default { * Hide the modal. */ 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) }, /** diff --git a/web-frontend/components/group/GroupForm.vue b/web-frontend/components/group/GroupForm.vue index ad46180e6..a5714eead 100644 --- a/web-frontend/components/group/GroupForm.vue +++ b/web-frontend/components/group/GroupForm.vue @@ -42,6 +42,9 @@ export default { values: { name: { required } } + }, + mounted() { + this.$refs.name.focus() } } </script> diff --git a/web-frontend/components/group/GroupsContext.vue b/web-frontend/components/group/GroupsContext.vue index 6982a5a46..34fc261b8 100644 --- a/web-frontend/components/group/GroupsContext.vue +++ b/web-frontend/components/group/GroupsContext.vue @@ -1,5 +1,5 @@ <template> - <Context class="select"> + <Context ref="groupsContext" class="select"> <div class="select-search"> <i class="select-search-icon fas fa-search"></i> <input @@ -13,28 +13,12 @@ <div class="loading"></div> </div> <ul v-if="!isLoading && isLoaded && groups.length > 0" class="select-items"> - <li + <GroupsContextItem v-for="group in searchAndSort(groups)" :key="group.id" - :ref="'groupSelect' + group.id" - class="select-item" - > - <div class="loading-overlay"></div> - <a class="select-item-link"> - <Editable - :ref="'groupRename' + group.id" - :value="group.name" - @change="renameGroup(group, $event)" - ></Editable> - </a> - <a - :ref="'groupOptions' + group.id" - class="select-item-options" - @click="toggleContext(group.id)" - > - <i class="fas fa-ellipsis-v"></i> - </a> - </li> + :group="group" + @selected="hide" + ></GroupsContextItem> </ul> <div v-if="!isLoading && isLoaded && groups.length == 0" @@ -42,22 +26,6 @@ > No results found </div> - <Context ref="groupsItemContext"> - <ul class="context-menu"> - <li> - <a @click="toggleRename(contextId)"> - <i class="context-menu-icon fas fa-fw fa-pen"></i> - Rename group - </a> - </li> - <li> - <a @click="deleteGroup(contextId)"> - <i class="context-menu-icon fas fa-fw fa-trash"></i> - Delete group - </a> - </li> - </ul> - </Context> <div class="select-footer"> <a class="select-footer-button" @click="$refs.createGroupModal.show()"> <i class="fas fa-plus"></i> @@ -72,18 +40,19 @@ import { mapGetters, mapState } from 'vuex' import CreateGroupModal from '@/components/group/CreateGroupModal' +import GroupsContextItem from '@/components/group/GroupsContextItem' import context from '@/mixins/context' export default { - name: 'GroupsItemContext', + name: 'GroupsContext', components: { - CreateGroupModal + CreateGroupModal, + GroupsContextItem }, mixins: [context], data() { return { - query: '', - contextId: -1 + query: '' } }, computed: { @@ -96,15 +65,14 @@ export default { }) }, 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) { this.$store.dispatch('group/loadAll') this.getRootContext().toggle(...args) }, - toggleContext(groupId) { - const target = this.$refs['groupOptions' + groupId][0] - this.contextId = groupId - this.$refs.groupsItemContext.toggle(target, 'bottom', 'right', 0) - }, searchAndSort(groups) { const query = this.query @@ -115,39 +83,6 @@ export default { // .sort((a, b) => { // return a.order - b.order // }) - }, - toggleRename(id) { - this.$refs.groupsItemContext.hide() - this.$refs['groupRename' + id][0].edit() - }, - renameGroup(group, event) { - const select = this.$refs['groupSelect' + group.id][0] - select.classList.add('select-item-loading') - - this.$store - .dispatch('group/update', { - id: group.id, - values: { - name: event.value - } - }) - .catch(() => { - // If something is going wrong we will reset the original value - const rename = this.$refs['groupRename' + group.id][0] - rename.set(event.oldValue) - }) - .then(() => { - select.classList.remove('select-item-loading') - }) - }, - deleteGroup(id) { - this.$refs.groupsItemContext.hide() - const select = this.$refs['groupSelect' + id][0] - select.classList.add('select-item-loading') - - this.$store.dispatch('group/delete', id).catch(() => { - select.classList.remove('select-item-loading') - }) } } } diff --git a/web-frontend/components/group/GroupsContextItem.vue b/web-frontend/components/group/GroupsContextItem.vue new file mode 100644 index 000000000..a5dbde24b --- /dev/null +++ b/web-frontend/components/group/GroupsContextItem.vue @@ -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> diff --git a/web-frontend/components/sidebar/ApplicationForm.vue b/web-frontend/components/sidebar/ApplicationForm.vue new file mode 100644 index 000000000..aa7522cbb --- /dev/null +++ b/web-frontend/components/sidebar/ApplicationForm.vue @@ -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> diff --git a/web-frontend/components/sidebar/CreateApplicationContext.vue b/web-frontend/components/sidebar/CreateApplicationContext.vue new file mode 100644 index 000000000..8e587435f --- /dev/null +++ b/web-frontend/components/sidebar/CreateApplicationContext.vue @@ -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> diff --git a/web-frontend/components/sidebar/CreateApplicationModal.vue b/web-frontend/components/sidebar/CreateApplicationModal.vue new file mode 100644 index 000000000..655d7cd36 --- /dev/null +++ b/web-frontend/components/sidebar/CreateApplicationModal.vue @@ -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> diff --git a/web-frontend/components/sidebar/Sidebar.vue b/web-frontend/components/sidebar/Sidebar.vue new file mode 100644 index 000000000..6f9c1185d --- /dev/null +++ b/web-frontend/components/sidebar/Sidebar.vue @@ -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> diff --git a/web-frontend/components/sidebar/SidebarApplication.vue b/web-frontend/components/sidebar/SidebarApplication.vue new file mode 100644 index 000000000..8a2a63e17 --- /dev/null +++ b/web-frontend/components/sidebar/SidebarApplication.vue @@ -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> diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js index 2e7bc2122..b927c29d8 100644 --- a/web-frontend/config/nuxt.config.base.js +++ b/web-frontend/config/nuxt.config.base.js @@ -1,8 +1,8 @@ export default { mode: 'universal', - /* - ** Headers of the page + /** + * Headers of the page */ head: { title: 'Baserow', @@ -12,18 +12,18 @@ export default { ] }, - /* - ** Customize the progress-bar color + /** + * Customize the progress-bar color */ loading: { color: '#fff' }, - /* - ** Global CSS + /** + * Global CSS */ css: ['@/assets/scss/default.scss'], - /* - ** Plugins to load before mounting the App + /** + * Plugins to load before mounting the App */ plugins: [ { src: '@/plugins/global.js' }, @@ -32,13 +32,17 @@ export default { { 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: { - middleware: 'authentication' + middleware: ['authentication', 'group'] }, env: { diff --git a/web-frontend/core/applications.js b/web-frontend/core/applications.js new file mode 100644 index 000000000..8e7380eff --- /dev/null +++ b/web-frontend/core/applications.js @@ -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 + } + } +} diff --git a/web-frontend/filters/lowercase.js b/web-frontend/filters/lowercase.js new file mode 100644 index 000000000..cef6ad767 --- /dev/null +++ b/web-frontend/filters/lowercase.js @@ -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() +} diff --git a/web-frontend/layouts/app.vue b/web-frontend/layouts/app.vue index 808956f51..681c4a542 100644 --- a/web-frontend/layouts/app.vue +++ b/web-frontend/layouts/app.vue @@ -57,6 +57,7 @@ <div class="sidebar-title"> <img src="@/static/img/logo.svg" alt="" /> </div> + <Sidebar></Sidebar> </nav> </div> <div class="sidebar-footer"> @@ -78,12 +79,14 @@ import { mapActions, mapGetters } from 'vuex' import Notifications from '@/components/notifications/Notifications' import GroupsContext from '@/components/group/GroupsContext' +import Sidebar from '@/components/sidebar/Sidebar' export default { middleware: 'authenticated', components: { GroupsContext, - Notifications + Notifications, + Sidebar }, computed: { ...mapGetters({ diff --git a/web-frontend/middleware/group.js b/web-frontend/middleware/group.js new file mode 100644 index 000000000..9fc2543b1 --- /dev/null +++ b/web-frontend/middleware/group.js @@ -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) + } + }) + } +} diff --git a/web-frontend/mixins/application.js b/web-frontend/mixins/application.js new file mode 100644 index 000000000..d98aa23ba --- /dev/null +++ b/web-frontend/mixins/application.js @@ -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' }) + }) + } +} diff --git a/web-frontend/mixins/context.js b/web-frontend/mixins/context.js index 784338d0f..cb4b55112 100644 --- a/web-frontend/mixins/context.js +++ b/web-frontend/mixins/context.js @@ -13,13 +13,13 @@ export default { } }, toggle(...args) { - this.getRootModal().toggle(...args) + this.getRootContext().toggle(...args) }, show(...args) { - this.getRootModal().show(...args) + this.getRootContext().show(...args) }, hide(...args) { - this.getRootModal().hide(...args) + this.getRootContext().hide(...args) } } } diff --git a/web-frontend/mixins/moveToBody.js b/web-frontend/mixins/moveToBody.js index c5f715f2c..a74c3ac81 100644 --- a/web-frontend/mixins/moveToBody.js +++ b/web-frontend/mixins/moveToBody.js @@ -1,26 +1,69 @@ 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) + data() { + return { + moveToBody: { + children: [], + hasMoved: false, + movedEventHandlers: [] } - $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. + * Because we want the to be able to stack nested elements that are moved to + * 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() { this.hide() @@ -28,5 +71,26 @@ export default { if (this.$el.parentNode) { 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) + } } } diff --git a/web-frontend/modules/database/application.js b/web-frontend/modules/database/application.js new file mode 100644 index 000000000..16a60c2b9 --- /dev/null +++ b/web-frontend/modules/database/application.js @@ -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 + } +} diff --git a/web-frontend/modules/database/components/Sidebar.vue b/web-frontend/modules/database/components/Sidebar.vue new file mode 100644 index 000000000..0145d5cf5 --- /dev/null +++ b/web-frontend/modules/database/components/Sidebar.vue @@ -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> diff --git a/web-frontend/modules/database/module.js b/web-frontend/modules/database/module.js new file mode 100644 index 000000000..ab867ce18 --- /dev/null +++ b/web-frontend/modules/database/module.js @@ -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) + }) +} diff --git a/web-frontend/modules/database/pages/Database.vue b/web-frontend/modules/database/pages/Database.vue new file mode 100644 index 000000000..a1017bf21 --- /dev/null +++ b/web-frontend/modules/database/pages/Database.vue @@ -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> diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js new file mode 100644 index 000000000..784b13c82 --- /dev/null +++ b/web-frontend/modules/database/plugin.js @@ -0,0 +1,5 @@ +import { DatabaseApplication } from '@/modules/database/application' + +export default ({ store }) => { + store.dispatch('application/register', new DatabaseApplication()) +} diff --git a/web-frontend/modules/database/routes.js b/web-frontend/modules/database/routes.js new file mode 100644 index 000000000..cc110db65 --- /dev/null +++ b/web-frontend/modules/database/routes.js @@ -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 + } + } +] diff --git a/web-frontend/pages/app/index.vue b/web-frontend/pages/app/index.vue index 5b4df3975..01d71e138 100644 --- a/web-frontend/pages/app/index.vue +++ b/web-frontend/pages/app/index.vue @@ -3,6 +3,17 @@ <h1>Welcome {{ user }}</h1> <p> {{ 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> </div> </template> @@ -15,7 +26,10 @@ export default { computed: { ...mapState({ 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 }) } } diff --git a/web-frontend/plugins/global.js b/web-frontend/plugins/global.js index b000c73f9..ca9de0e26 100644 --- a/web-frontend/plugins/global.js +++ b/web-frontend/plugins/global.js @@ -4,6 +4,10 @@ import Context from '@/components/Context' import Modal from '@/components/Modal' import Editable from '@/components/Editable' +import lowercase from '@/filters/lowercase' + Vue.component('Context', Context) Vue.component('Modal', Modal) Vue.component('Editable', Editable) + +Vue.filter('lowercase', lowercase) diff --git a/web-frontend/services/application.js b/web-frontend/services/application.js new file mode 100644 index 000000000..359d0e61e --- /dev/null +++ b/web-frontend/services/application.js @@ -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}/`) + } +} diff --git a/web-frontend/store/application.js b/web-frontend/store/application.js new file mode 100644 index 000000000..0dfeb8552 --- /dev/null +++ b/web-frontend/store/application.js @@ -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] + } +} diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js index ceb43ab44..00bff617c 100644 --- a/web-frontend/store/auth.js +++ b/web-frontend/store/auth.js @@ -2,6 +2,7 @@ import jwtDecode from 'jwt-decode' import AuthService from '@/services/auth' import { setToken, unsetToken } from '@/utils/auth' +import { unsetGroupCookie } from '@/utils/group' export const state = () => ({ refreshing: false, @@ -53,9 +54,12 @@ export const actions = { * Logs off the user by removing the token as a cookie and clearing the user * data. */ - logoff({ commit }) { + logoff({ commit, dispatch }) { unsetToken(this.app.$cookies) + unsetGroupCookie(this.app.$cookies) 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 diff --git a/web-frontend/store/group.js b/web-frontend/store/group.js index 4d4ab10a1..d19eab34c 100644 --- a/web-frontend/store/group.js +++ b/web-frontend/store/group.js @@ -1,11 +1,17 @@ -// import { set } from 'vue' - 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 = () => ({ loaded: false, loading: false, - items: [] + items: [], + selected: {} }) export const mutations = { @@ -16,9 +22,20 @@ export const mutations = { state.loading = loading }, 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) { + item = populateGroup(item) state.items.push(item) }, UPDATE_ITEM(state, values) { @@ -28,15 +45,48 @@ export const mutations = { 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 = { + /** + * If not already loading or loaded it will trigger the fetchAll action which + * will load all the groups for the user. + */ loadAll({ state, dispatch }) { if (!state.loaded && !state.loading) { 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 }) { commit('SET_LOADING', true) @@ -52,21 +102,79 @@ export const actions = { commit('SET_LOADING', false) }) }, + /** + * Creates a new group with the given values. + */ create({ commit }, values) { return GroupService.create(values).then(({ data }) => { commit('ADD_ITEM', data) }) }, - update({ commit }, { id, values }) { - return GroupService.update(id, values).then(({ data }) => { - commit('UPDATE_ITEM', data) - }) + /** + * Updates the values of the group with the provided id. + */ + 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(() => { - console.log(id) - commit('DELETE_ITEM', id) - }) + /** + * Deletes an existing group with the provided 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) { 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 } } diff --git a/web-frontend/store/notification.js b/web-frontend/store/notification.js index b1746c03f..10c240a5a 100644 --- a/web-frontend/store/notification.js +++ b/web-frontend/store/notification.js @@ -15,6 +15,9 @@ export const mutations = { } export const actions = { + /** + * Shows a notification message to the user. + */ add({ commit }, { type, title, message }) { commit('ADD', { id: uuid(), diff --git a/web-frontend/utils/error.js b/web-frontend/utils/error.js new file mode 100644 index 000000000..5eebe08c6 --- /dev/null +++ b/web-frontend/utils/error.js @@ -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 } + ) + } +} diff --git a/web-frontend/utils/group.js b/web-frontend/utils/group.js new file mode 100644 index 000000000..0666bf107 --- /dev/null +++ b/web-frontend/utils/group.js @@ -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) +}