mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-04 21:25:24 +00:00
added possibility to select an application and navigate the route
This commit is contained in:
parent
b866e8f156
commit
15ba3dd043
17 changed files with 377 additions and 20 deletions
backend
src/baserow/api/v0/applications
tests/baserow/api/v0/applications
web-frontend
assets/scss/components
components/sidebar
config
core
mixins
modules/database
pages/app
services
store
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
29
web-frontend/mixins/application.js
Normal file
29
web-frontend/mixins/application.js
Normal file
|
@ -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' })
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
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>
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
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>
|
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
|
||||
}
|
||||
}
|
||||
]
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
Loading…
Add table
Reference in a new issue