Update store and overlay components to new api

This commit is contained in:
axolotle 2021-02-19 18:36:22 +01:00
parent b130aeda29
commit 9bc365f32a
10 changed files with 223 additions and 173 deletions

View file

@ -3,8 +3,8 @@
v-bind="$attrs" ref="self"
flush :class="{ 'fixed-height': fixedHeight, 'bordered': bordered }"
>
<b-list-group-item v-for="({ type, text }, i) in messages" :key="i">
<span class="status" :class="'bg-' + type" />
<b-list-group-item v-for="({ color, text }, i) in messages" :key="i">
<span class="status" :class="'bg-' + color" />
<span v-html="text" />
</b-list-group-item>
</b-list-group>

View file

@ -1,38 +1,39 @@
<template>
<div class="query-header w-100" v-on="$listeners" v-bind="$attrs">
<!-- STATUS -->
<span class="status" :class="['bg-' + color, statusSize]" :aria-label="$t('api.query_status.' + action.status)" />
<span class="status" :class="['bg-' + color, statusSize]" :aria-label="$t('api.query_status.' + request.status)" />
<!-- ACTION DESCRIPTION -->
<strong class="action-desc">
{{ action.uri | readableUri }}
<small>({{ $t('history.methods.' + action.method) }})</small>
<!-- REQUEST DESCRIPTION -->
<strong class="request-desc">
{{ request.uri | readableUri }}
<small>({{ $t('history.methods.' + request.method) }})</small>
</strong>
<div>
<div v-if="request.errors || request.warnings">
<!-- WEBSOCKET ERRORS COUNT -->
<span class="count" v-if="errorsCount">
{{ errorsCount }}<icon iname="bug" class="text-danger ml-1" />
<span class="count" v-if="request.errors">
{{ request.errors }}<icon iname="bug" class="text-danger ml-1" />
</span>
<!-- WEBSOCKET WARNINGS COUNT -->
<span class="count" v-if="warningsCount">
{{ warningsCount }}<icon iname="warning" class="text-warning ml-1" />
<span class="count" v-if="request.warnings">
{{ request.warnings }}<icon iname="warning" class="text-warning ml-1" />
</span>
</div>
<!-- VIEW ERRO BUTTON -->
<!-- VIEW ERROR BUTTON -->
<b-button
v-if="showError && action.status === 'error'"
v-if="showError && request.error"
size="sm" pill
class="error-btn ml-auto py-0"
variant="danger"
@click="reviewError"
>
<small v-t="'api_error.view_error'" />
</b-button>
<!-- TIME DISPLAY -->
<time v-if="showTime" :datetime="action.date | hour" :class="!showError || action.status !== 'error' ? 'ml-auto' : 'ml-2'">
{{ action.date | hour }}
<time v-if="showTime" :datetime="request.date | hour" :class="request.error ? 'ml-2' : 'ml-auto'">
{{ request.date | hour }}
</time>
</div>
</template>
@ -42,11 +43,10 @@ export default {
name: 'QueryHeader',
props: {
action: { type: Object, required: true },
request: { type: Object, required: true },
statusSize: { type: String, default: '' },
showTime: { type: Boolean, default: false },
showError: { type: Boolean, default: false },
truncate: { type: Boolean, default: true }
showError: { type: Boolean, default: false }
},
computed: {
@ -57,21 +57,27 @@ export default {
warning: 'warning',
error: 'danger'
}
return statuses[this.action.status]
return statuses[this.request.status]
},
errorsCount () {
return this.action.messages.filter(({ type }) => type === 'danger').length
return this.request.messages.filter(({ type }) => type === 'danger').length
},
warningsCount () {
return this.action.messages.filter(({ type }) => type === 'warning').length
return this.request.messages.filter(({ type }) => type === 'warning').length
}
},
methods: {
reviewError () {
this.$store.dispatch('REVIEW_ERROR', this.request)
}
},
filters: {
readableUri (uri) {
return uri.split('?')[0].replace('/', ' > ')
return uri.split('?')[0].split('/').join(' > ') // replace('/', ' > ')
},
hour (date) {
@ -110,6 +116,11 @@ div {
}
}
time {
min-width: 3.5rem;
text-align: right;
}
.count {
display: flex;
align-items: center;
@ -117,7 +128,7 @@ div {
}
@include media-breakpoint-down(xs) {
.xs-hide .action-desc {
.xs-hide .request-desc {
display: none;
}
}

View file

@ -203,7 +203,6 @@
"history": {
"title": "History",
"last_action": "Last action:",
"current_action": "Current action:",
"methods": {
"DELETE": "delete",
"GET": "read",

View file

@ -29,7 +29,7 @@ const router = new VueRouter({
router.beforeEach((to, from, next) => {
if (store.getters.error) {
store.dispatch('DELETE_ERROR')
store.dispatch('DISMISS_ERROR', true)
}
// Allow if connected or route is not protected
if (store.getters.connected || to.meta.noAuth) {

View file

@ -90,40 +90,21 @@ export default {
},
actions: {
'FETCH' ({ state, commit, rootState }, { uri, param, storeKey = uri, cache = rootState.cache }) {
'GET' ({ state, commit, rootState }, { uri, param, storeKey = uri, options = {} }) {
const noCache = !rootState.cache || options.noCache || false
const currentState = param ? state[storeKey][param] : state[storeKey]
// if data has already been queried, simply return
if (currentState !== undefined && cache) return currentState
if (currentState !== undefined && !noCache) return currentState
return api.get(param ? `${uri}/${param}` : uri).then(responseData => {
return api.fetch('GET', param ? `${uri}/${param}` : uri, null, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('SET_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey]
})
},
'FETCH_ALL' ({ state, commit, rootState }, queries) {
return Promise.all(queries.map(({ uri, param, storeKey = uri, cache = rootState.cache }) => {
const currentState = param ? state[storeKey][param] : state[storeKey]
// if data has already been queried, simply return the state as cached
if (currentState !== undefined && cache) {
return { cached: currentState }
}
return api.get(param ? `${uri}/${param}` : uri).then(responseData => {
return { storeKey, param, responseData }
})
})).then(responsesData => {
return responsesData.map(({ storeKey, param, responseData, cached = undefined }) => {
if (cached !== undefined) return cached
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('SET_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey]
})
})
},
'POST' ({ state, commit }, { uri, data, storeKey = uri }) {
return api.post(uri, data).then(responseData => {
'POST' ({ state, commit }, { uri, storeKey = uri, data, options }) {
return api.fetch('POST', uri, data, options).then(responseData => {
// FIXME api/domains returns null
if (responseData === null) responseData = data
responseData = responseData[storeKey] ? responseData[storeKey] : responseData
@ -132,16 +113,16 @@ export default {
})
},
'PUT' ({ state, commit }, { uri, param, data, storeKey = uri }) {
return api.put(param ? `${uri}/${param}` : uri, data).then(responseData => {
'PUT' ({ state, commit }, { uri, param, storeKey = uri, data, options }) {
return api.fetch('PUT', param ? `${uri}/${param}` : uri, data, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('UPDATE_' + storeKey.toUpperCase(), param ? [param, data] : data)
return param ? state[storeKey][param] : state[storeKey]
})
},
'DELETE' ({ commit }, { uri, param, data = {}, storeKey = uri }) {
return api.delete(param ? `${uri}/${param}` : uri, data).then(() => {
'DELETE' ({ commit }, { uri, param, storeKey = uri, data, options }) {
return api.fetch('DELETE', param ? `${uri}/${param}` : uri, data, options).then(() => {
commit('DEL_' + storeKey.toUpperCase(), param)
})
}
@ -164,8 +145,7 @@ export default {
})
},
// not cached
user: state => name => state.users_details[name],
user: state => name => state.users_details[name], // not cached
domains: state => state.domains,

View file

@ -5,95 +5,70 @@ import { timeout } from '@/helpers/commons'
export default {
state: {
host: window.location.host,
connected: localStorage.getItem('connected') === 'true',
yunohost: null, // yunohost app infos: Object {version, repo}
error: null,
waiting: false,
history: []
host: window.location.host, // String
connected: localStorage.getItem('connected') === 'true', // Boolean
yunohost: null, // Object { version, repo }
waiting: false, // Boolean
history: [], // Array of `request`
requests: [], // Array of `request`
error: null // null || request
},
mutations: {
'SET_CONNECTED' (state, connected) {
localStorage.setItem('connected', connected)
state.connected = connected
'SET_CONNECTED' (state, boolean) {
localStorage.setItem('connected', boolean)
state.connected = boolean
},
'SET_YUNOHOST_INFOS' (state, yunohost) {
state.yunohost = yunohost
},
'UPDATE_WAITING' (state, boolean) {
'SET_WAITING' (state, boolean) {
state.waiting = boolean
},
'ADD_HISTORY_ENTRY' (state, [uri, method, date]) {
state.history.push({ uri, method, date, status: 'pending', messages: [] })
'ADD_REQUEST' (state, request) {
if (state.requests.length > 10) {
// We do not remove requests right after it resolves since an error might bring
// one back to life but we can safely remove some here.
state.requests.shift()
}
state.requests.push(request)
},
'UPDATE_LAST_HISTORY_ENTRY' (state, [key, value]) {
Vue.set(state.history[state.history.length - 1], key, value)
'UPDATE_REQUEST' (state, { request, key, value }) {
// This rely on data persistance and reactivity.
Vue.set(request, key, value)
},
'ADD_MESSAGE' (state, message) {
state.history[state.history.length - 1].messages.push(message)
'REMOVE_REQUEST' (state, request) {
const index = state.requests.lastIndexOf(request)
state.requests.splice(index, 1)
},
'UPDATE_PROGRESS' (state, progress) {
Vue.set(state.history[state.history.length - 1], 'progress', progress)
'ADD_HISTORY_ACTION' (state, request) {
state.history.push(request)
},
'SET_ERROR' (state, error) {
state.error = error
'ADD_MESSAGE' (state, { message, type }) {
const request = state.history[state.history.length - 1]
request.messages.push(message)
if (['error', 'warning'].includes(type)) {
request[type + 's']++
}
},
'SET_ERROR' (state, request) {
if (request) {
state.error = request
} else {
state.error = null
}
}
},
actions: {
'LOGIN' ({ dispatch }, password) {
// Entering a wrong password will trigger a 401 api response.
// action `DISCONNECT` will then be triggered by the response handler but will not
// redirect to `/login` so the view can display the catched error.
return api.post('login', { password }).then(() => {
dispatch('CONNECT')
})
},
'LOGOUT' ({ dispatch }) {
return api.get('logout').then(() => {
dispatch('DISCONNECT')
})
},
'RESET_CONNECTED' ({ commit }) {
commit('SET_CONNECTED', false)
commit('SET_YUNOHOST_INFOS', null)
},
'DISCONNECT' ({ dispatch, commit }, route) {
dispatch('RESET_CONNECTED')
commit('UPDATE_WAITING', false)
if (router.currentRoute.name === 'login') return
router.push({
name: 'login',
// Add a redirect query if next route is not unknown (like `logout`) or `login`
query: route && !['login', null].includes(route.name)
? { redirect: route.path }
: {}
})
},
'CONNECT' ({ commit, dispatch }) {
commit('SET_CONNECTED', true)
dispatch('GET_YUNOHOST_INFOS')
router.push(router.currentRoute.query.redirect || { name: 'home' })
},
'GET_YUNOHOST_INFOS' ({ commit }) {
return api.get('versions').then(versions => {
commit('SET_YUNOHOST_INFOS', versions.yunohost)
})
},
'CHECK_INSTALL' ({ dispatch }, retry = 2) {
// this action will try to query the `/installed` route 3 times every 5 s with
// a timeout of the same delay.
@ -108,29 +83,85 @@ export default {
})
},
'WAITING_FOR_RESPONSE' ({ commit }, [uri, method]) {
commit('UPDATE_WAITING', true)
commit('ADD_HISTORY_ENTRY', [uri, method, Date.now()])
'CONNECT' ({ commit, dispatch }) {
commit('SET_CONNECTED', true)
dispatch('GET_YUNOHOST_INFOS')
router.push(router.currentRoute.query.redirect || { name: 'home' })
},
'SERVER_RESPONDED' ({ state, commit }, success) {
const action = state.history.length ? state.history[state.history.length - 1] : null
if (action) {
let status = success ? 'success' : 'error'
if (status === 'success' && action.messages.some(msg => msg.type === 'danger' || msg.type === 'warning')) {
status = 'warning'
}
commit('UPDATE_LAST_HISTORY_ENTRY', ['status', status])
'RESET_CONNECTED' ({ commit }) {
commit('SET_CONNECTED', false)
commit('SET_YUNOHOST_INFOS', null)
},
'DISCONNECT' ({ dispatch }, route = router.currentRoute) {
dispatch('RESET_CONNECTED')
if (router.currentRoute.name === 'login') return
router.push({
name: 'login',
// Add a redirect query if next route is not unknown (like `logout`) or `login`
query: route && !['login', null].includes(route.name)
? { redirect: route.path }
: {}
})
},
'LOGIN' ({ dispatch }, password) {
return api.post('login', { password }, { websocket: false }).then(() => {
dispatch('CONNECT')
})
},
'LOGOUT' ({ dispatch }) {
dispatch('DISCONNECT')
return api.get('logout')
},
'GET_YUNOHOST_INFOS' ({ commit }) {
return api.get('versions').then(versions => {
commit('SET_YUNOHOST_INFOS', versions.yunohost)
})
},
'INIT_REQUEST' ({ commit }, { method, uri, initial, wait, websocket }) {
let request = { method, uri, initial, status: 'pending' }
if (websocket) {
request = { ...request, messages: [], date: Date.now(), warnings: 0, errors: 0 }
commit('ADD_HISTORY_ACTION', request)
}
commit('UPDATE_WAITING', false)
commit('ADD_REQUEST', request)
if (wait) {
setTimeout(() => {
// Display the waiting modal only if the request takes some time.
if (request.status === 'pending') {
commit('SET_WAITING', true)
}
}, 400)
}
return request
},
'DISPATCH_MESSAGE' ({ commit }, messages) {
const typeToColor = { error: 'danger' }
'END_REQUEST' ({ commit }, { request, success, wait }) {
let status = success ? 'success' : 'error'
if (success && (request.warnings || request.errors)) {
status = 'warning'
}
commit('UPDATE_REQUEST', { request, key: 'status', value: status })
if (wait) {
// Remove the overlay after a short delay to allow an error to display withtout flickering.
setTimeout(() => {
commit('SET_WAITING', false)
}, 100)
}
},
'DISPATCH_MESSAGE' ({ commit }, { request, messages }) {
for (const type in messages) {
const message = {
text: messages[type],
type: type in typeToColor ? typeToColor[type] : type
color: type === 'error' ? 'danger' : type
}
let progressBar = message.text.match(/^\[#*\+*\.*\] > /)
if (progressBar) {
@ -140,15 +171,15 @@ export default {
for (const char of progressBar) {
if (char in progress) progress[char] += 1
}
commit('UPDATE_PROGRESS', Object.values(progress))
commit('UPDATE_REQUEST', { request, key: 'progress', value: Object.values(progress) })
}
if (message.text) {
commit('ADD_MESSAGE', message)
commit('ADD_MESSAGE', { request, message, type })
}
}
},
'HANDLE_ERROR' ({ state, commit, dispatch }, error) {
'HANDLE_ERROR' ({ commit, dispatch }, error) {
if (error.code === 401) {
// Unauthorized
dispatch('DISCONNECT')
@ -156,23 +187,47 @@ export default {
// Errors that have produced logs
router.push({ name: 'tool-log', params: { name: error.logRef } })
} else {
// The request is temporarely stored in the error for reference, but we reverse
// the ownership to stay generic.
const request = error.request
delete error.request
Vue.set(request, 'error', error)
// Display the error in a modal on the current view.
commit('SET_ERROR', error)
commit('SET_ERROR', request)
}
},
'DELETE_ERROR' ({ commit }) {
'REVIEW_ERROR' ({ commit }, request) {
request.review = true
commit('SET_ERROR', request)
},
'DISMISS_ERROR' ({ commit, state }, { initial, review = false }) {
if (initial && !review) {
// In case of an initial request (data that is needed by a view to render itself),
// try to go back so the user doesn't get stuck at a never ending skeleton view.
if (history.length > 2) {
history.back()
} else {
// if the url was opened in a new tab, return to home
router.push({ name: 'home' })
}
}
commit('SET_ERROR', null)
}
},
getters: {
host: state => state.host,
connected: state => (state.connected),
yunohost: state => (state.yunohost),
connected: state => state.connected,
yunohost: state => state.yunohost,
error: state => state.error,
waiting: state => state.waiting,
history: state => state.history,
lastAction: state => state.history[state.history.length - 1]
lastAction: state => state.history[state.history.length - 1],
currentRequest: state => {
const request = state.requests.find(({ status }) => status === 'pending')
return request || state.requests[state.requests.length - 1]
}
}
}

View file

@ -30,11 +30,11 @@
<pre><code>{{ error.traceback }}</code></pre>
</template>
<template v-if="hasMessages">
<template v-if="messages">
<p class="my-2">
<strong v-t="'api_error.server_said'" />
</p>
<message-list-group :messages="action.messages" bordered />
<message-list-group :messages="messages" bordered />
</template>
</b-card-body>
@ -49,35 +49,34 @@
</template>
<script>
import { mapGetters } from 'vuex'
import MessageListGroup from '@/components/MessageListGroup'
export default {
name: 'ErrorPage',
name: 'ErrorDisplay',
components: {
MessageListGroup
},
props: {
action: { type: Object, required: true }
request: { type: [Object, null], default: null }
},
computed: {
...mapGetters(['error']),
error () {
return this.request.error
},
hasMessages () {
return this.action && this.action.messages.length > 0
messages () {
const messages = this.request.messages
if (messages && messages.length > 0) return messages
return null
}
},
methods: {
dismiss () {
if (this.error && this.error.method === 'GET') {
history.back()
}
this.$store.dispatch('DELETE_ERROR')
this.$store.dispatch('DISMISS_ERROR', this.request)
}
}
}

View file

@ -23,9 +23,9 @@
@click.prevent="onLastActionClick"
@keyup.enter.space.prevent="onLastActionClick"
>
<small>{{ $t('history.' + (lastAction.status === 'pending' ? 'current_action' : 'last_action')) }}</small>
<small>{{ $t('history.last_action') }}</small>
</b-button>
<query-header v-if="lastAction" :action="lastAction" class="w-auto ml-2 xs-hide" />
<query-header v-if="lastAction" :request="lastAction" class="w-auto ml-2 xs-hide" />
</b-card-header>
<b-collapse id="console-collapse" v-model="open">
@ -43,7 +43,7 @@
<!-- ACTION DESC -->
<query-header
role="tab" v-b-toggle="action.messages.length ? 'messages-collapse-' + i : false"
:action="action" show-time show-error
:request="action" show-time show-error
/>
</b-card-header>

View file

@ -7,12 +7,13 @@
<slot name="default" />
<template v-slot:overlay>
<b-card no-body class="card-overlay" v-if="lastAction">
<b-card no-body class="card-overlay">
<b-card-header header-bg-variant="white">
<query-header :action="lastAction" status-size="lg" />
<query-header :request="error || currentRequest" status-size="lg" />
</b-card-header>
<component :is="error ? 'ErrorDisplay' : 'WaitingDisplay'" :action="lastAction" />
<component v-if="error" :is="'ErrorDisplay'" :request="error" />
<component v-else :is="'WaitingDisplay'" :request="currentRequest" />
</b-card>
</template>
</b-overlay>
@ -26,13 +27,13 @@ import QueryHeader from '@/components/QueryHeader'
export default {
name: 'ViewLockOverlay',
computed: mapGetters(['waiting', 'error', 'lastAction']),
components: {
ErrorDisplay,
WaitingDisplay,
QueryHeader
}
},
computed: mapGetters(['waiting', 'error', 'currentRequest'])
}
</script>
@ -53,8 +54,13 @@ export default {
::v-deep {
.card-body {
padding: 1.5rem;
padding-bottom: 0;
max-height: 60vh;
overflow-y: auto;
& > :last-child {
margin-bottom: 1.5rem;
}
}
.card-footer {

View file

@ -16,7 +16,7 @@
<div v-else class="custom-spinner my-4" :class="spinner" />
<message-list-group
v-if="hasMessages" :messages="action.messages"
v-if="hasMessages" :messages="request.messages"
bordered fixed-height auto-scroll
/>
</b-card-body>
@ -35,28 +35,28 @@ export default {
},
props: {
action: { type: Object, required: true }
request: { type: Object, required: true }
},
computed: {
...mapGetters(['spinner']),
hasMessages () {
return this.action && this.action.messages.length > 0
return this.request.messages && this.request.messages.length > 0
},
progress () {
const progress = this.action.progress
const progress = this.request.progress
if (!progress) return null
return {
values: progress, max: progress.reduce((sum, value) => (sum + value), 0)
values: progress,
max: progress.reduce((sum, value) => (sum + value), 0)
}
}
}
}
</script>
<style lang="scss" scoped>
.custom-spinner {
animation: 4s linear infinite;