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..3d201a4b0 100644 --- a/backend/src/baserow/api/v0/applications/views.py +++ b/backend/src/baserow/api/v0/applications/views.py @@ -56,6 +56,18 @@ class ApplicationView(APIView): permission_classes = (IsAuthenticated,) core_handler = CoreHandler() + @map_exceptions({ + UserNotIngroupError: ERROR_USER_NOT_IN_GROUP + }) + 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/web-frontend/assets/scss/components/_tree.scss b/web-frontend/assets/scss/components/_tree.scss index 0a7d1347a..63d193983 100644 --- a/web-frontend/assets/scss/components/_tree.scss +++ b/web-frontend/assets/scss/components/_tree.scss @@ -129,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; diff --git a/web-frontend/components/sidebar/SidebarApplication.vue b/web-frontend/components/sidebar/SidebarApplication.vue index 5d33da4e7..8a2a63e17 100644 --- a/web-frontend/components/sidebar/SidebarApplication.vue +++ b/web-frontend/components/sidebar/SidebarApplication.vue @@ -2,11 +2,12 @@ <li class="tree-item" :class="{ + active: application._.selected, 'tree-item-loading': application._.loading }" > <div class="tree-action"> - <a class="tree-link"> + <a class="tree-link" @click="selectApplication(application)"> <i class="tree-type fas" :class="'fa-' + application._.type.iconClass" @@ -42,6 +43,16 @@ </ul> </Context> </div> + <template + v-if=" + application._.selected && application._.type.hasSelectedSidebarComponent + " + > + <component + :is="getSelectedApplicationComponent(application)" + :application="application" + ></component> + </template> </li> </template> @@ -83,6 +94,24 @@ export default { 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) @@ -90,6 +119,12 @@ export default { 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() } } } diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js index 2efdff84d..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,8 +32,8 @@ export default { { src: '@/plugins/vuelidate.js' } ], - /* - ** Nuxt.js modules + /** + * Nuxt.js modules */ modules: [ '@nuxtjs/axios', diff --git a/web-frontend/core/applications.js b/web-frontend/core/applications.js index 219936f05..8e7380eff 100644 --- a/web-frontend/core/applications.js +++ b/web-frontend/core/applications.js @@ -30,6 +30,14 @@ export class Application { 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 @@ -40,19 +48,32 @@ export class Application { 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 Error('The type name of an application must be set.') + throw new Error('The type name of an application must be set.') } if (this.iconClass === null) { - throw Error('The icon class of an application must be set.') + throw new Error('The icon class of an application must be set.') } if (this.name === null) { - throw Error('The name of an application must be set.') + 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.') } } @@ -63,7 +84,9 @@ export class Application { return { type: this.type, iconClass: this.iconClass, - name: this.name + name: this.name, + routeName: this.routeName, + hasSelectedSidebarComponent: this.getSelectedSidebarComponent() !== null } } } diff --git a/web-frontend/mixins/application.js b/web-frontend/mixins/application.js new file mode 100644 index 000000000..d7b1875c9 --- /dev/null +++ b/web-frontend/mixins/application.js @@ -0,0 +1,29 @@ +import { notify404 } from '@/utils/error' + +/** + * This mixin can be used in combination with the page an 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/modules/database/application.js b/web-frontend/modules/database/application.js index 8a18f25ea..16a60c2b9 100644 --- a/web-frontend/modules/database/application.js +++ b/web-frontend/modules/database/application.js @@ -1,4 +1,5 @@ import { Application } from '@/core/applications' +import Sidebar from '@/modules/database/components/Sidebar' export class DatabaseApplication extends Application { getType() { @@ -12,4 +13,12 @@ export class DatabaseApplication extends Application { 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 index 296c82ab6..ab867ce18 100644 --- a/web-frontend/modules/database/module.js +++ b/web-frontend/modules/database/module.js @@ -1,9 +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/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 035ffa406..01d71e138 100644 --- a/web-frontend/pages/app/index.vue +++ b/web-frontend/pages/app/index.vue @@ -9,6 +9,11 @@ {{ 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> diff --git a/web-frontend/services/application.js b/web-frontend/services/application.js index e8313d217..359d0e61e 100644 --- a/web-frontend/services/application.js +++ b/web-frontend/services/application.js @@ -7,6 +7,9 @@ export default { 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) }, diff --git a/web-frontend/store/application.js b/web-frontend/store/application.js index 905dfa0e2..0dfeb8552 100644 --- a/web-frontend/store/application.js +++ b/web-frontend/store/application.js @@ -3,9 +3,12 @@ import ApplicationService from '@/services/application' import { notify404, notifyError } from '@/utils/error' function populateApplication(application, getters) { + const type = getters.getApplicationByType(application.type) + application._ = { - type: getters.getApplicationByType(application.type).serialize(), - loading: false + type: type.serialize(), + loading: false, + selected: false } return application } @@ -13,7 +16,8 @@ function populateApplication(application, getters) { export const state = () => ({ applications: {}, loading: false, - items: [] + items: [], + selected: {} }) export const mutations = { @@ -39,6 +43,19 @@ 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 = {} } } @@ -165,6 +182,77 @@ export const actions = { ' 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) + } + }) + } } } @@ -172,6 +260,9 @@ 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) }, diff --git a/web-frontend/store/group.js b/web-frontend/store/group.js index 4ee74ae17..2736d53ea 100644 --- a/web-frontend/store/group.js +++ b/web-frontend/store/group.js @@ -135,7 +135,6 @@ export const actions = { return GroupService.delete(group.id) .then(() => { if (group._.selected) { - console.log('calling unselect') dispatch('unselect', group) } @@ -159,6 +158,16 @@ export const actions = { 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. */