Merge pull request #437 from YunoHost/enh-configpanels

Enh configpanels
This commit is contained in:
Alexandre Aubin 2022-02-07 13:50:34 +01:00 committed by GitHub
commit 895bab5e26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 288 additions and 166 deletions

View file

@ -40,9 +40,9 @@
<!-- The `key` on router-view make sure that if a link points to a page that <!-- The `key` on router-view make sure that if a link points to a page that
use the same component as the previous one, it will be refreshed --> use the same component as the previous one, it will be refreshed -->
<transition v-if="transitions" :name="transitionName"> <transition v-if="transitions" :name="transitionName">
<router-view class="animated" :key="$route.fullPath" /> <router-view class="animated" :key="routerKey" />
</transition> </transition>
<router-view v-else class="static" :key="$route.fullPath" /> <router-view v-else class="static" :key="routerKey" />
</main> </main>
</view-lock-overlay> </view-lock-overlay>
@ -86,25 +86,15 @@ export default {
ViewLockOverlay ViewLockOverlay
}, },
data () {
return {
transitionName: null
}
},
computed: { computed: {
...mapGetters(['connected', 'yunohost', 'transitions', 'waiting']) ...mapGetters([
}, 'connected',
'yunohost',
watch: { 'routerKey',
// Set the css class to animate the components transition 'transitions',
'$route' (to, from) { 'transitionName',
if (!this.transitions || from.name === null) return 'waiting'
// Use the breadcrumb array length as a direction indicator ])
const toDepth = to.meta.breadcrumb.length
const fromDepth = from.meta.breadcrumb.length
this.transitionName = toDepth < fromDepth ? 'slide-right' : 'slide-left'
}
}, },
methods: { methods: {

View file

@ -0,0 +1,70 @@
<template>
<abstract-form
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }"
@submit.prevent.stop="$emit('submit', panel.id)"
>
<slot name="tab-top" />
<template v-if="panel.help" #disclaimer>
<div class="alert alert-info" v-html="help" />
</template>
<slot name="tab-before" />
<template v-for="section in panel.sections">
<div v-if="isVisible(section.visible, section)" :key="section.id" class="mb-5">
<b-card-title v-if="section.name" title-tag="h3">
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
</b-card-title>
<template v-for="(field, fname) in section.fields">
<form-field
v-if="isVisible(field.visible, field)" :key="fname"
v-model="forms[panel.id][fname]" v-bind="field" :validation="validation[fname]"
/>
</template>
</div>
</template>
<slot name="tab-after" />
</abstract-form>
</template>
<script>
import { configPanelsFieldIsVisible } from '@/helpers/yunohostArguments'
export default {
name: 'ConfigPanel',
props: {
tabId: { type: String, required: true },
panels: { type: Array, default: undefined },
forms: { type: Object, default: undefined },
v: { type: Object, default: undefined }
},
computed: {
panel () {
return this.panels.find(panel => panel.id === this.tabId)
},
validation () {
return this.v.forms[this.panel.id]
}
},
methods: {
isVisible (expression, field) {
return configPanelsFieldIsVisible(expression, field, this.forms)
}
}
}
</script>
<style lang="scss" scoped>
.card-title {
margin-bottom: 1em;
border-bottom: solid 1px #aaa;
}
</style>

View file

@ -1,67 +1,50 @@
<template> <template>
<b-card no-body> <routable-tabs
<b-tabs fill pills card> :routes="routes_"
<slot name="before" /> v-bind="{ panels, forms, v: $v }"
v-on="$listeners"
<tab-form />
v-for="{ name, id, sections, help, serverError } in panels" :key="id"
v-bind="{ name, id: id + '-form', validation: $v.forms[id], serverError }"
@submit.prevent.stop="$emit('submit', id)"
>
<template v-if="help" #disclaimer>
<div class="alert alert-info" v-html="help" />
</template>
<slot :name="id + '-tab-before'" />
<template v-for="section in sections">
<div v-if="isVisible(section.visible, section)" :key="section.id" class="mb-5">
<b-card-title v-if="section.name" title-tag="h3">
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
</b-card-title>
<template v-for="(field, fname) in section.fields">
<form-field
v-if="isVisible(field.visible, field)" :key="fname"
v-model="forms[id][fname]" v-bind="field" :validation="$v.forms[id][fname]"
/>
</template>
</div>
</template>
<slot :name="id + '-tab-after'" />
</tab-form>
<slot name="default" />
</b-tabs>
</b-card>
</template> </template>
<script> <script>
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import { configPanelsFieldIsVisible } from '@/helpers/yunohostArguments'
export default { export default {
name: 'ConfigPanels', name: 'ConfigPanels',
components: {
RoutableTabs: () => import('@/components/RoutableTabs.vue')
},
mixins: [validationMixin], mixins: [validationMixin],
props: { props: {
panels: { type: Array, default: undefined }, panels: { type: Array, default: undefined },
forms: { type: Object, default: undefined }, forms: { type: Object, default: undefined },
validations: { type: Object, default: undefined } validations: { type: Object, default: undefined },
errors: { type: Object, default: undefined }, // never used
routes: { type: Array, default: null },
noRedirect: { type: Boolean, default: false }
},
computed: {
routes_ () {
if (this.routes) return this.routes
return this.panels.map(panel => ({
to: { params: { tabId: panel.id } },
text: panel.name,
icon: panel.icon || 'wrench'
}))
}
}, },
validations () { validations () {
const v = this.validations return { forms: this.validations }
return v ? { forms: v } : null
}, },
methods: { created () {
isVisible (expression, field) { if (!this.noRedirect && !this.$route.params.tabId) {
return configPanelsFieldIsVisible(expression, field, this.forms) this.$router.replace({ params: { tabId: this.panels[0].id } })
} }
} }
} }

View file

@ -0,0 +1,32 @@
<template>
<b-card no-body>
<b-card-header header-tag="nav">
<b-nav card-header fill pills>
<b-nav-item
v-for="route in routes" :key="route.text"
:to="route.to" exact exact-active-class="active"
>
<icon v-if="route.icon" :iname="route.icon" />
{{ route.text }}
</b-nav-item>
</b-nav>
</b-card-header>
<!-- Bind extra props to the child view and forward child events to parent -->
<router-view v-bind="$attrs" v-on="$listeners" />
</b-card>
</template>
<script>
export default {
name: 'RoutableTabs',
// Thanks to `v-bind="$attrs"` and `inheritAttrs: false`, this component can forward
// arbitrary attributes (props) directly to its children.
inheritAttrs: false,
props: {
routes: { type: Array, required: true }
}
}
</script>

View file

@ -1,40 +1,37 @@
<template> <template>
<b-tab no-body> <div>
<template #title>
<icon :iname="icon" /> {{ name }}
</template>
<b-card-body> <b-card-body>
<slot name="disclaimer" /> <slot name="disclaimer" />
<b-form <b-form
:id="id" :inline="inline" :class="formClasses" :id="id" :inline="inline" :class="formClasses"
@submit.prevent="onSubmit" novalidate @submit.prevent="onSubmit" novalidate
> >
<slot name="default" /> <slot name="default" />
<slot name="server-error"> <slot name="server-error" v-bind="{ errorFeedback }">
<b-alert <b-alert
v-if="errorFeedback"
variant="danger" class="my-3" icon="ban" variant="danger" class="my-3" icon="ban"
:show="errorFeedback !== ''" v-html="errorFeedback" v-html="errorFeedback"
/> />
</slot> </slot>
</b-form> </b-form>
</b-card-body> </b-card-body>
<b-card-footer> <b-card-footer v-if="!noFooter">
<b-button type="submit" variant="success" :form="id"> <slot name="footer">
{{ submitText ? submitText : $t('save') }} <b-button type="submit" variant="success" :form="id">
</b-button> {{ submitText || $t('save') }}
</b-button>
</slot>
</b-card-footer> </b-card-footer>
</b-tab> </div>
</template> </template>
<script> <script>
export default { export default {
name: 'TabForm', name: 'AbstractForm',
props: { props: {
id: { type: String, default: 'ynh-form' }, id: { type: String, default: 'ynh-form' },
@ -43,8 +40,7 @@ export default {
serverError: { type: String, default: '' }, serverError: { type: String, default: '' },
inline: { type: Boolean, default: false }, inline: { type: Boolean, default: false },
formClasses: { type: [Array, String, Object], default: null }, formClasses: { type: [Array, String, Object], default: null },
name: { type: String, required: true }, noFooter: { type: Boolean, default: false }
icon: { type: String, default: 'wrench' }
}, },
computed: { computed: {

View file

@ -1,13 +1,13 @@
<template> <template>
<b-breadcrumb v-if="routesList"> <b-breadcrumb v-if="breadcrumb.length">
<b-breadcrumb-item to="/"> <b-breadcrumb-item to="/">
<span class="sr-only">{{ $t('home') }}</span> <span class="sr-only">{{ $t('home') }}</span>
<icon iname="home" /> <icon iname="home" />
</b-breadcrumb-item> </b-breadcrumb-item>
<b-breadcrumb-item <b-breadcrumb-item
v-for="{ name, text } in breadcrumb" :key="name" v-for="({ name, text }, i) in breadcrumb" :key="name"
:to="{ name }" :active="name === $route.name" :to="{ name }" :active="i === breadcrumb.length - 1"
> >
{{ text }} {{ text }}
</b-breadcrumb-item> </b-breadcrumb-item>
@ -15,41 +15,13 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'
export default { export default {
name: 'Breadcrumb', name: 'Breadcrumb',
computed: { computed: {
routesList () { ...mapGetters(['breadcrumb'])
const routesList = this.$route.meta.breadcrumb
return routesList && routesList.length ? routesList : null
},
breadcrumb () {
if (!this.routesList) return
// Get current params to pass it to potential previous routes
const currentParams = this.$route.params
return this.routesList.map(name => {
const { trad, param } = this.getRouteArgs(name)
let text = ''
// if a traduction key string has been given and we also need to pass
// the route param as a variable.
if (trad && param) {
text = this.$i18n.t(trad, { [param]: currentParams[param] })
} else if (trad) {
text = this.$i18n.t(trad)
} else {
text = currentParams[param]
}
return { name, text }
})
}
},
methods: {
getRouteArgs (routeName) {
const route = this.$router.options.routes.find(route => route.name === routeName)
return route ? route.meta.args : {}
}
} }
} }
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-bind="$attr" :class="['custom-spinner', spinner]" /> <div :class="['custom-spinner', spinner]" />
</template> </template>
<script> <script>

View file

@ -295,7 +295,7 @@ export function formatYunoHostConfigPanels (data) {
} }
for (const { id: panelId, name, help, sections } of data.panels) { for (const { id: panelId, name, help, sections } of data.panels) {
const panel = { id: panelId, sections: [] } const panel = { id: panelId, sections: [], serverError: '' }
result.forms[panelId] = {} result.forms[panelId] = {}
result.validations[panelId] = {} result.validations[panelId] = {}
result.errors[panelId] = {} result.errors[panelId] = {}

View file

@ -1,6 +1,5 @@
import Vue from 'vue' import Vue from 'vue'
import VueRouter from 'vue-router' import VueRouter from 'vue-router'
import i18n from '@/i18n'
import routes from './routes' import routes from './routes'
import store from '@/store' import store from '@/store'
@ -29,6 +28,10 @@ const router = new VueRouter({
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (store.getters.transitions && from.name !== null) {
store.dispatch('UPDATE_TRANSITION_NAME', { to, from })
}
if (store.getters.error) { if (store.getters.error) {
store.dispatch('DISMISS_ERROR', true) store.dispatch('DISMISS_ERROR', true)
} }
@ -41,29 +44,8 @@ router.beforeEach((to, from, next) => {
}) })
router.afterEach((to, from) => { router.afterEach((to, from) => {
// Display a simplified breadcrumb as the document title. store.dispatch('UPDATE_ROUTER_KEY', { to, from })
const routeParams = to.params store.dispatch('UPDATE_BREADCRUMB', { to, from })
let breadcrumb = to.meta.breadcrumb
if (breadcrumb.length === 0) {
breadcrumb = [to.name]
} else if (breadcrumb.length > 2) {
breadcrumb = breadcrumb.slice(breadcrumb.length - 2)
}
const title = breadcrumb.map(name => {
const route = routes.find(route => route.name === name)
const { trad, param } = route ? route.meta.args : {}
// if a traduction key string has been given and we also need to pass
// the route param as a variable.
if (trad && param) {
return i18n.t(trad, { [param]: routeParams[param] })
} else if (trad) {
return i18n.t(trad)
}
return routeParams[param]
}).reverse().join(' / ')
document.title = `${title} | ${i18n.t('yunohost_admin')}`
}) })
export default router export default router

View file

@ -17,10 +17,8 @@ const routes = [
name: 'home', name: 'home',
path: '/', path: '/',
component: Home, component: Home,
// Leave the empty breadcrumb as it is used by the animated transition to know which way to go
meta: { meta: {
args: { trad: 'home' }, args: { trad: 'home' }
breadcrumb: []
} }
}, },
@ -30,8 +28,7 @@ const routes = [
component: Login, component: Login,
meta: { meta: {
noAuth: true, noAuth: true,
args: { trad: 'login' }, args: { trad: 'login' }
breadcrumb: []
} }
}, },
@ -42,12 +39,10 @@ const routes = [
name: 'post-install', name: 'post-install',
path: '/postinstall', path: '/postinstall',
component: () => import(/* webpackChunkName: "views/post-install" */ '@/views/PostInstall'), component: () => import(/* webpackChunkName: "views/post-install" */ '@/views/PostInstall'),
// Leave the breadcrumb
meta: { meta: {
noAuth: true, noAuth: true,
args: { trad: 'postinstall.title' }, args: { trad: 'postinstall.title' }
breadcrumb: [] }
}
}, },
/* /*
@ -77,7 +72,7 @@ const routes = [
component: () => import(/* webpackChunkName: "views/user/import" */ '@/views/user/UserImport'), component: () => import(/* webpackChunkName: "views/user/import" */ '@/views/user/UserImport'),
props: true, props: true,
meta: { meta: {
args: { param: 'name' }, args: { trad: 'users_import' },
breadcrumb: ['user-list', 'user-import'] breadcrumb: ['user-list', 'user-import']
} }
}, },
@ -156,14 +151,23 @@ const routes = [
} }
}, },
{ {
name: 'domain-config', // no need for name here, only children are visited
path: '/domains/:name/config', path: '/domains/:name/config',
component: () => import(/* webpackChunkName: "views/domain/dns" */ '@/views/domain/DomainConfig'), component: () => import(/* webpackChunkName: "views/domain/config" */ '@/views/domain/DomainConfig'),
props: true, props: true,
meta: { children: [
args: { trad: 'config' }, {
breadcrumb: ['domain-list', 'domain-info', 'domain-config'] name: 'domain-config',
} path: ':tabId?',
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
props: true,
meta: {
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
args: { trad: 'config' },
breadcrumb: ['domain-list', 'domain-info', 'domain-config']
}
}
]
}, },
{ {
name: 'domain-dns', name: 'domain-dns',
@ -248,14 +252,23 @@ const routes = [
} }
}, },
{ {
name: 'app-config-panel', // no need for name here, only children are visited
path: '/apps/:id/config-panel', path: '/apps/:id/config-panel',
component: () => import(/* webpackChunkName: "views/apps/config" */ '@/views/app/AppConfigPanel'), component: () => import(/* webpackChunkName: "views/apps/config" */ '@/views/app/AppConfigPanel'),
props: true, props: true,
meta: { children: [
args: { trad: 'app_config_panel' }, {
breadcrumb: ['app-list', 'app-info', 'app-config-panel'] name: 'app-config-panel',
} path: ':tabId?',
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
props: true,
meta: {
routerParams: ['id'],
args: { trad: 'app_config_panel' },
breadcrumb: ['app-list', 'app-info', 'app-config-panel']
}
}
]
}, },
/* /*

View file

@ -2,7 +2,7 @@ import Vue from 'vue'
import router from '@/router' import router from '@/router'
import i18n from '@/i18n' import i18n from '@/i18n'
import api from '@/api' import api from '@/api'
import { timeout, isObjectLiteral } from '@/helpers/commons' import { timeout, isEmptyValue, isObjectLiteral } from '@/helpers/commons'
export default { export default {
state: { state: {
@ -15,7 +15,10 @@ export default {
requests: [], // Array of `request` requests: [], // Array of `request`
error: null, // null || request error: null, // null || request
historyTimer: null, // null || setTimeout id historyTimer: null, // null || setTimeout id
tempMessages: [] // array of messages tempMessages: [], // Array of messages
routerKey: undefined, // String if current route has params
breadcrumb: [], // Array of routes
transitionName: null // String of CSS class if transitions are enabled
}, },
mutations: { mutations: {
@ -87,6 +90,18 @@ export default {
} else { } else {
state.error = null state.error = null
} }
},
'SET_ROUTER_KEY' (state, key) {
state.routerKey = key
},
'SET_BREADCRUMB' (state, breadcrumb) {
state.breadcrumb = breadcrumb
},
'SET_TRANSITION_NAME' (state, transitionName) {
state.transitionName = transitionName
} }
}, },
@ -264,6 +279,72 @@ export default {
'DISMISS_WARNING' ({ commit, state }, request) { 'DISMISS_WARNING' ({ commit, state }, request) {
commit('SET_WAITING', false) commit('SET_WAITING', false)
Vue.delete(request, 'showWarningMessage') Vue.delete(request, 'showWarningMessage')
},
'UPDATE_ROUTER_KEY' ({ commit }, { to, from }) {
if (isEmptyValue(to.params)) {
commit('SET_ROUTER_KEY', undefined)
return
}
// If the next route uses the same component as the previous one, Vue will not
// recreate an instance of that component, so hooks like `created()` will not be
// triggered and data will not be fetched.
// For routes with params, we create a unique key to force the recreation of a view.
// Params can be declared in route `meta` to stricly define which params should be
// taken into account.
const params = to.meta.routerParams
? to.meta.routerParams.map(key => to.params[key])
: Object.values(to.params)
commit('SET_ROUTER_KEY', `${to.name}-${params.join('-')}`)
},
'UPDATE_BREADCRUMB' ({ commit }, { to, from }) {
function getRouteNames (route) {
if (route.meta.breadcrumb) return route.meta.breadcrumb
const parentRoute = route.matched.slice().reverse().find(route => route.meta.breadcrumb)
if (parentRoute) return parentRoute.meta.breadcrumb
return []
}
function formatRoute (route) {
const { trad, param } = route.meta.args || {}
let text = ''
// if a traduction key string has been given and we also need to pass
// the route param as a variable.
if (trad && param) {
text = i18n.t(trad, { [param]: to.params[param] })
} else if (trad) {
text = i18n.t(trad)
} else {
text = to.params[param]
}
return { name: route.name, text }
}
const routeNames = getRouteNames(to)
const allRoutes = router.getRoutes()
const breadcrumb = routeNames.map(name => {
const route = allRoutes.find(route => route.name === name)
return formatRoute(route)
})
commit('SET_BREADCRUMB', breadcrumb)
function getTitle (breadcrumb) {
if (breadcrumb.length === 0) return formatRoute(to).text
return (breadcrumb.length > 2 ? breadcrumb.slice(-2) : breadcrumb).map(route => route.text).reverse().join(' / ')
}
// Display a simplified breadcrumb as the document title.
document.title = `${getTitle(breadcrumb)} | ${i18n.t('yunohost_admin')}`
},
'UPDATE_TRANSITION_NAME' ({ state, commit }, { to, from }) {
// Use the breadcrumb array length as a direction indicator
const toDepth = (to.meta.breadcrumb || []).length
const fromDepth = (from.meta.breadcrumb || []).length
commit('SET_TRANSITION_NAME', toDepth < fromDepth ? 'slide-right' : 'slide-left')
} }
}, },
@ -279,6 +360,9 @@ export default {
currentRequest: state => { currentRequest: state => {
const request = state.requests.find(({ status }) => status === 'pending') const request = state.requests.find(({ status }) => status === 'pending')
return request || state.requests[state.requests.length - 1] return request || state.requests[state.requests.length - 1]
} },
routerKey: state => state.routerKey,
breadcrumb: state => state.breadcrumb,
transitionName: state => state.transitionName
} }
} }