Merge pull request #553 from YunoHost/pre-vue3-prettier-yarn

[2. pre-vue3] prettier + yarn to match yunohost-portal stack
This commit is contained in:
Bram 2024-03-05 21:24:35 +01:00 committed by GitHub
commit d0cca4d423
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
116 changed files with 5775 additions and 5466 deletions

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install npm dependencies - name: Install yarn dependencies
run: cd app && npm ci run: cd app && yarn install --frozen-lockfile
- name: Run linter - name: Run linter
run: cd app && npm run lint run: cd app && yarn lint

View file

@ -2,37 +2,17 @@ module.exports = {
root: true, root: true,
env: { env: {
es2021: true, es2021: true,
node: true node: true,
}, },
extends: [ extends: [
'plugin:vue/strongly-recommended', 'plugin:vue/strongly-recommended',
'eslint:recommended', 'eslint:recommended',
'standard' 'plugin:prettier/recommended',
], ],
rules: { rules: {
'vue/max-attributes-per-line': [
'error',
{
singleline: 3,
multiline: 3
}
],
'vue/multi-word-component-names': 'off', // FIXME this should be adressed at some point
'no-console': 'warn',
'template-curly-spacing': 'off',
camelcase: 'warn',
indent: 'off',
'no-irregular-whitespace': 'off',
'no-unused-vars': [ 'no-unused-vars': [
'warn', 'warn',
{ varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' } { varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
], ],
quotes: 'warn', },
'no-multiple-empty-lines': [
'error',
{
max: 2
}
]
}
} }

1
app/.prettierignore Normal file
View file

@ -0,0 +1 @@
dist/

19
app/.prettierrc Normal file
View file

@ -0,0 +1,19 @@
{
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"overrides": [
{
"files": "**/*.json",
"options": {
"tabWidth": 4
}
},
{
"files": "./*.json",
"options": {
"tabWidth": 2
}
}
]
}

View file

@ -1,23 +1,26 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=yes"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.png" />
<title>YunoHost Admin</title>
</head>
<head> <body>
<meta charset="utf-8"> <noscript>
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <strong>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=yes"> We're sorry but YunoHost Admin doesn't work properly without JavaScript
<meta name="format-detection" content="telephone=no" /> enabled. Please enable it to continue.
<meta name="robots" content="noindex, nofollow"> </strong>
<link rel="icon" href="/favicon.png"> </noscript>
<title>YunoHost Admin</title> <div id="app"></div>
</head> <script type="module" src="/src/main.js"></script>
</body>
<body> </html>
<noscript>
<strong>We're sorry but YunoHost Admin doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2703
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,10 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint --ext .js,.vue src", "lint:js": "eslint --ext \".ts,.vue,.cjs,.js\" --ignore-path ../.gitignore .",
"lint-fix": "lint --fix" "lint:prettier": "prettier --check .",
"lint": "yarn lint:js && yarn lint:prettier",
"lintfix": "prettier --write --list-different . && yarn lint:js --fix"
}, },
"dependencies": { "dependencies": {
"@fontsource/fira-code": "^4.5.13", "@fontsource/fira-code": "^4.5.13",
@ -28,11 +30,13 @@
"@vitejs/plugin-vue2": "^2.2.0", "@vitejs/plugin-vue2": "^2.2.0",
"bootstrap": "^4.6.0", "bootstrap": "^4.6.0",
"eslint": "^8.36.0", "eslint": "^8.36.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.10.0", "eslint-plugin-vue": "^9.10.0",
"popper.js": "^1.16.0", "popper.js": "^1.16.0",
"portal-vue": "^2.1.7", "portal-vue": "^2.1.7",
"prettier": "^3.2.5",
"sass": "^1.60.0", "sass": "^1.60.0",
"standard": "^17.0.0",
"vite": "^4.2.1" "vite": "^4.2.1"
}, },
"browserslist": [ "browserslist": [

View file

@ -4,23 +4,22 @@
<header> <header>
<BNavbar> <BNavbar>
<BNavbarBrand <BNavbarBrand
:to="{ name: 'home' }" :disabled="waiting" :to="{ name: 'home' }"
exact exact-active-class="active" :disabled="waiting"
exact
exact-active-class="active"
> >
<span v-if="theme"> <span v-if="theme">
<img alt="YunoHost logo" src="./assets/logo_light.png" width="40"> <img alt="YunoHost logo" src="./assets/logo_light.png" width="40" />
</span> </span>
<span v-else> <span v-else>
<img alt="YunoHost logo" src="./assets/logo_dark.png" width="40"> <img alt="YunoHost logo" src="./assets/logo_dark.png" width="40" />
</span> </span>
</BNavbarBrand> </BNavbarBrand>
<BNavbarNav class="ml-auto"> <BNavbarNav class="ml-auto">
<li class="nav-item"> <li class="nav-item">
<BButton <BButton :href="ssoLink" variant="primary" size="sm" block>
:href="ssoLink"
variant="primary" size="sm" block
>
{{ $t('user_interface_link') }} <YIcon iname="user" /> {{ $t('user_interface_link') }} <YIcon iname="user" />
</BButton> </BButton>
</li> </li>
@ -28,7 +27,9 @@
<li class="nav-item" v-show="connected"> <li class="nav-item" v-show="connected">
<BButton <BButton
@click.prevent="logout" @click.prevent="logout"
variant="outline-dark" block size="sm" variant="outline-dark"
block
size="sm"
> >
{{ $t('logout') }} <YIcon iname="sign-out" /> {{ $t('logout') }} <YIcon iname="sign-out" />
</BButton> </BButton>
@ -58,18 +59,32 @@
<footer class="py-3 mt-auto"> <footer class="py-3 mt-auto">
<nav> <nav>
<BNav class="justify-content-center"> <BNav class="justify-content-center">
<BNavItem href="https://yunohost.org/docs" target="_blank" link-classes="text-secondary"> <BNavItem
href="https://yunohost.org/docs"
target="_blank"
link-classes="text-secondary"
>
<YIcon iname="book" /> {{ $t('footer.documentation') }} <YIcon iname="book" /> {{ $t('footer.documentation') }}
</BNavItem> </BNavItem>
<BNavItem href="https://yunohost.org/help" target="_blank" link-classes="text-secondary"> <BNavItem
href="https://yunohost.org/help"
target="_blank"
link-classes="text-secondary"
>
<YIcon iname="life-ring" /> {{ $t('footer.help') }} <YIcon iname="life-ring" /> {{ $t('footer.help') }}
</BNavItem> </BNavItem>
<BNavItem href="https://donate.yunohost.org/" target="_blank" link-classes="text-secondary"> <BNavItem
href="https://donate.yunohost.org/"
target="_blank"
link-classes="text-secondary"
>
<YIcon iname="heart" /> {{ $t('footer.donate') }} <YIcon iname="heart" /> {{ $t('footer.donate') }}
</BNavItem> </BNavItem>
<BNavText <BNavText
v-if="yunohost" id="yunohost-version" class="ml-md-auto text-center" v-if="yunohost"
id="yunohost-version"
class="ml-md-auto text-center"
> >
<span v-html="$t('footer_version', yunohost)" /> <span v-html="$t('footer_version', yunohost)" />
</BNavText> </BNavText>
@ -89,7 +104,7 @@ export default {
components: { components: {
HistoryConsole, HistoryConsole,
ViewLockOverlay ViewLockOverlay,
}, },
computed: { computed: {
@ -101,29 +116,31 @@ export default {
'transitionName', 'transitionName',
'waiting', 'waiting',
'theme', 'theme',
'ssoLink' 'ssoLink',
]) ]),
}, },
methods: { methods: {
async logout () { async logout() {
this.$store.dispatch('LOGOUT') this.$store.dispatch('LOGOUT')
} },
}, },
// This hook is only triggered at page first load // This hook is only triggered at page first load
created () { created() {
this.$store.dispatch('ON_APP_CREATED') this.$store.dispatch('ON_APP_CREATED')
}, },
mounted () { mounted() {
// Unlock copypasta on log view // Unlock copypasta on log view
const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp'] const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp']
let copypastastep = 0 let copypastastep = 0
document.addEventListener('keydown', ({ key }) => { document.addEventListener('keydown', ({ key }) => {
if (key === copypastaCode[copypastastep++]) { if (key === copypastaCode[copypastastep++]) {
if (copypastastep === copypastaCode.length) { if (copypastastep === copypastaCode.length) {
document.getElementsByClassName('unselectable').forEach((element) => element.classList.remove('unselectable')) document
.getElementsByClassName('unselectable')
.forEach((element) => element.classList.remove('unselectable'))
copypastastep = 0 copypastastep = 0
} }
} else { } else {
@ -132,7 +149,18 @@ export default {
}) })
// Konamicode ;P // Konamicode ;P
const konamiCode = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'] const konamiCode = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'b',
'a',
]
let konamistep = 0 let konamistep = 0
document.addEventListener('keydown', ({ key }) => { document.addEventListener('keydown', ({ key }) => {
if (key === konamiCode[konamistep++]) { if (key === konamiCode[konamistep++]) {
@ -157,7 +185,7 @@ export default {
} }
document.documentElement.setAttribute('dark-theme', this.theme) // updates the data-theme attribute document.documentElement.setAttribute('dark-theme', this.theme) // updates the data-theme attribute
} },
} }
</script> </script>
@ -177,14 +205,14 @@ header {
padding: 1rem 0; padding: 1rem 0;
img { img {
width: 70px; width: 70px;
} }
.navbar-nav { .navbar-nav {
flex-direction: column; flex-direction: column;
li { li {
margin: .2rem 0; margin: 0.2rem 0;
} }
} }
} }
@ -195,15 +223,17 @@ main {
// Routes transition // Routes transition
.animated { .animated {
transition: all .15s ease-in-out; transition: all 0.15s ease-in-out;
} }
.slide-left-enter, .slide-right-leave-active { .slide-left-enter,
.slide-right-leave-active {
position: absolute; position: absolute;
width: 100%; width: 100%;
top: 0; top: 0;
transform: translate(100vw, 0); transform: translate(100vw, 0);
} }
.slide-left-leave-active, .slide-right-enter { .slide-left-leave-active,
.slide-right-enter {
position: absolute; position: absolute;
width: 100%; width: 100%;
top: 0; top: 0;
@ -229,7 +259,7 @@ footer {
.nav-item { .nav-item {
& + .nav-item a::before { & + .nav-item a::before {
content: "•"; content: '•';
width: 1rem; width: 1rem;
display: inline-block; display: inline-block;
margin-left: -1.15rem; margin-left: -1.15rem;

View file

@ -6,7 +6,6 @@
import store from '@/store' import store from '@/store'
import { openWebSocket, getResponseData, handleError } from './handlers' import { openWebSocket, getResponseData, handleError } from './handlers'
/** /**
* Options available for an API call. * Options available for an API call.
* *
@ -17,7 +16,6 @@ import { openWebSocket, getResponseData, handleError } from './handlers'
* @property {Boolean} asFormData - if `true`, will send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`). * @property {Boolean} asFormData - if `true`, will send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`).
*/ */
/** /**
* Representation of an API call for `api.fetchAll` * Representation of an API call for `api.fetchAll`
* *
@ -26,8 +24,7 @@ import { openWebSocket, getResponseData, handleError } from './handlers'
* @property {String|Object} 1 - "uri", uri to call as string or as an object for cached uris. * @property {String|Object} 1 - "uri", uri to call as string or as an object for cached uris.
* @property {Object|null} 2 - "data" * @property {Object|null} 2 - "data"
* @property {Options} 3 - "options" * @property {Options} 3 - "options"
*/ */
/** /**
* Converts an object literal into an `URLSearchParams` that can be turned into a * Converts an object literal into an `URLSearchParams` that can be turned into a
@ -38,11 +35,15 @@ import { openWebSocket, getResponseData, handleError } from './handlers'
* @param {Boolean} [options.addLocale=false] - Option to append the locale to the query string. * @param {Boolean} [options.addLocale=false] - Option to append the locale to the query string.
* @return {URLSearchParams} * @return {URLSearchParams}
*/ */
export function objectToParams (obj, { addLocale = false } = {}, formData = false) { export function objectToParams(
const urlParams = (formData) ? new FormData() : new URLSearchParams() obj,
{ addLocale = false } = {},
formData = false,
) {
const urlParams = formData ? new FormData() : new URLSearchParams()
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(v => urlParams.append(key, v)) value.forEach((v) => urlParams.append(key, v))
} else { } else {
urlParams.append(key, value) urlParams.append(key, value)
} }
@ -53,7 +54,6 @@ export function objectToParams (obj, { addLocale = false } = {}, formData = fals
return urlParams return urlParams
} }
export default { export default {
options: { options: {
credentials: 'include', credentials: 'include',
@ -64,11 +64,10 @@ export default {
// Auto header is : // Auto header is :
// "Accept": "*/*", // "Accept": "*/*",
'X-Requested-With': 'XMLHttpRequest' 'X-Requested-With': 'XMLHttpRequest',
} },
}, },
/** /**
* Generic method to fetch the api without automatic response handling. * Generic method to fetch the api without automatic response handling.
* *
@ -78,15 +77,22 @@ export default {
* @param {Options} [options={ wait = true, websocket = true, initial = false, asFormData = false }] * @param {Options} [options={ wait = true, websocket = true, initial = false, asFormData = false }]
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
async fetch ( async fetch(
method, method,
uri, uri,
data = {}, data = {},
humanKey = null, humanKey = null,
{ wait = true, websocket = true, initial = false, asFormData = false } = {} { wait = true, websocket = true, initial = false, asFormData = false } = {},
) { ) {
// `await` because Vuex actions returns promises by default. // `await` because Vuex actions returns promises by default.
const request = await store.dispatch('INIT_REQUEST', { method, uri, humanKey, initial, wait, websocket }) const request = await store.dispatch('INIT_REQUEST', {
method,
uri,
humanKey,
initial,
wait,
websocket,
})
if (websocket) { if (websocket) {
await openWebSocket(request) await openWebSocket(request)
@ -96,17 +102,22 @@ export default {
if (method === 'GET') { if (method === 'GET') {
uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}` uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`
} else { } else {
options = { ...options, method, body: objectToParams(data, { addLocale: true }, true) } options = {
...options,
method,
body: objectToParams(data, { addLocale: true }, true),
}
} }
const response = await fetch('/yunohost/api/' + uri, options) const response = await fetch('/yunohost/api/' + uri, options)
const responseData = await getResponseData(response) const responseData = await getResponseData(response)
store.dispatch('END_REQUEST', { request, success: response.ok, wait }) store.dispatch('END_REQUEST', { request, success: response.ok, wait })
return response.ok ? responseData : handleError(request, response, responseData) return response.ok
? responseData
: handleError(request, response, responseData)
}, },
/** /**
* Api multiple queries helper. * Api multiple queries helper.
* Those calls will act as one (declare optional waiting for one but still create history entries for each) * Those calls will act as one (declare optional waiting for one but still create history entries for each)
@ -117,14 +128,16 @@ export default {
* @param {Boolean} * @param {Boolean}
* @return {Promise<Array|Error>} Promise that resolve the api responses data or an error. * @return {Promise<Array|Error>} Promise that resolve the api responses data or an error.
*/ */
async fetchAll (queries, { wait, initial } = {}) { async fetchAll(queries, { wait, initial } = {}) {
const results = [] const results = []
if (wait) store.commit('SET_WAITING', true) if (wait) store.commit('SET_WAITING', true)
try { try {
for (const [method, uri, data, humanKey, options = {}] of queries) { for (const [method, uri, data, humanKey, options = {}] of queries) {
if (wait) options.wait = false if (wait) options.wait = false
if (initial) options.initial = true if (initial) options.initial = true
results.push(await this[method.toLowerCase()](uri, data, humanKey, options)) results.push(
await this[method.toLowerCase()](uri, data, humanKey, options),
)
} }
} finally { } finally {
// Stop waiting even if there is an error. // Stop waiting even if there is an error.
@ -134,7 +147,6 @@ export default {
return results return results
}, },
/** /**
* Api get helper function. * Api get helper function.
* *
@ -143,13 +155,13 @@ export default {
* @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`) * @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`)
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
get (uri, data = null, humanKey = null, options = {}) { get(uri, data = null, humanKey = null, options = {}) {
options = { websocket: false, wait: false, ...options } options = { websocket: false, wait: false, ...options }
if (typeof uri === 'string') return this.fetch('GET', uri, null, humanKey, options) if (typeof uri === 'string')
return this.fetch('GET', uri, null, humanKey, options)
return store.dispatch('GET', { ...uri, humanKey, options }) return store.dispatch('GET', { ...uri, humanKey, options })
}, },
/** /**
* Api post helper function. * Api post helper function.
* *
@ -158,12 +170,12 @@ export default {
* @param {Options} [options={}] - options to apply to the call * @param {Options} [options={}] - options to apply to the call
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
post (uri, data = {}, humanKey = null, options = {}) { post(uri, data = {}, humanKey = null, options = {}) {
if (typeof uri === 'string') return this.fetch('POST', uri, data, humanKey, options) if (typeof uri === 'string')
return this.fetch('POST', uri, data, humanKey, options)
return store.dispatch('POST', { ...uri, data, humanKey, options }) return store.dispatch('POST', { ...uri, data, humanKey, options })
}, },
/** /**
* Api put helper function. * Api put helper function.
* *
@ -172,12 +184,12 @@ export default {
* @param {Options} [options={}] - options to apply to the call * @param {Options} [options={}] - options to apply to the call
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
put (uri, data = {}, humanKey = null, options = {}) { put(uri, data = {}, humanKey = null, options = {}) {
if (typeof uri === 'string') return this.fetch('PUT', uri, data, humanKey, options) if (typeof uri === 'string')
return this.fetch('PUT', uri, data, humanKey, options)
return store.dispatch('PUT', { ...uri, data, humanKey, options }) return store.dispatch('PUT', { ...uri, data, humanKey, options })
}, },
/** /**
* Api delete helper function. * Api delete helper function.
* *
@ -186,8 +198,9 @@ export default {
* @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`) * @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`)
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
delete (uri, data = {}, humanKey = null, options = {}) { delete(uri, data = {}, humanKey = null, options = {}) {
if (typeof uri === 'string') return this.fetch('DELETE', uri, data, humanKey, options) if (typeof uri === 'string')
return this.fetch('DELETE', uri, data, humanKey, options)
return store.dispatch('DELETE', { ...uri, data, humanKey, options }) return store.dispatch('DELETE', { ...uri, data, humanKey, options })
}, },
@ -199,24 +212,27 @@ export default {
* @param {Number} initialDelay - delay before calling the API for the first time in ms. * @param {Number} initialDelay - delay before calling the API for the first time in ms.
* @return {Promise<undefined|Error>} * @return {Promise<undefined|Error>}
*/ */
tryToReconnect ({ attemps = 5, delay = 2000, initialDelay = 0 } = {}) { tryToReconnect({ attemps = 5, delay = 2000, initialDelay = 0 } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const api = this const api = this
function reconnect (n) { function reconnect(n) {
api.get('logout', {}, { key: 'reconnecting' }).then(resolve).catch(err => { api
if (err.name === 'APIUnauthorizedError') { .get('logout', {}, { key: 'reconnecting' })
resolve() .then(resolve)
} else if (n < 1) { .catch((err) => {
reject(err) if (err.name === 'APIUnauthorizedError') {
} else { resolve()
setTimeout(() => reconnect(n - 1), delay) } else if (n < 1) {
} reject(err)
}) } else {
setTimeout(() => reconnect(n - 1), delay)
}
})
} }
if (initialDelay > 0) setTimeout(() => reconnect(attemps), initialDelay) if (initialDelay > 0) setTimeout(() => reconnect(attemps), initialDelay)
else reconnect(attemps) else reconnect(attemps)
}) })
} },
} }

View file

@ -5,10 +5,13 @@
import i18n from '@/i18n' import i18n from '@/i18n'
class APIError extends Error { class APIError extends Error {
constructor (request, { url, status, statusText }, { error }) { constructor(request, { url, status, statusText }, { error }) {
super(error ? error.replaceAll('\n', '<br>') : i18n.t('error_server_unexpected')) super(
error
? error.replaceAll('\n', '<br>')
: i18n.t('error_server_unexpected'),
)
const urlObj = new URL(url) const urlObj = new URL(url)
this.name = 'APIError' this.name = 'APIError'
this.code = status this.code = status
@ -18,7 +21,7 @@ class APIError extends Error {
this.path = urlObj.pathname + urlObj.search this.path = urlObj.pathname + urlObj.search
} }
log () { log() {
/* eslint-disable-next-line */ /* eslint-disable-next-line */
console.error(`${this.name} (${this.code}): ${this.uri}\n${this.message}`) console.error(`${this.name} (${this.code}): ${this.uri}\n${this.message}`)
} }
@ -26,26 +29,24 @@ class APIError extends Error {
// Log (Special error to trigger a redirect to a log page) // Log (Special error to trigger a redirect to a log page)
class APIErrorLog extends APIError { class APIErrorLog extends APIError {
constructor (method, response, errorData) { constructor(method, response, errorData) {
super(method, response, errorData) super(method, response, errorData)
this.logRef = errorData.log_ref this.logRef = errorData.log_ref
this.name = 'APIErrorLog' this.name = 'APIErrorLog'
} }
} }
// 0 — (means "the connexion has been closed" apparently) // 0 — (means "the connexion has been closed" apparently)
class APIConnexionError extends APIError { class APIConnexionError extends APIError {
constructor (method, response) { constructor(method, response) {
super(method, response, { error: i18n.t('error_connection_interrupted') }) super(method, response, { error: i18n.t('error_connection_interrupted') })
this.name = 'APIConnexionError' this.name = 'APIConnexionError'
} }
} }
// 400 — Bad Request // 400 — Bad Request
class APIBadRequestError extends APIError { class APIBadRequestError extends APIError {
constructor (method, response, errorData) { constructor(method, response, errorData) {
super(method, response, errorData) super(method, response, errorData)
this.name = 'APIBadRequestError' this.name = 'APIBadRequestError'
this.key = errorData.error_key this.key = errorData.error_key
@ -53,45 +54,40 @@ class APIBadRequestError extends APIError {
} }
} }
// 401 — Unauthorized // 401 — Unauthorized
class APIUnauthorizedError extends APIError { class APIUnauthorizedError extends APIError {
constructor (method, response, errorData) { constructor(method, response, errorData) {
super(method, response, { error: i18n.t('unauthorized') }) super(method, response, { error: i18n.t('unauthorized') })
this.name = 'APIUnauthorizedError' this.name = 'APIUnauthorizedError'
} }
} }
// 404 — Not Found // 404 — Not Found
class APINotFoundError extends APIError { class APINotFoundError extends APIError {
constructor (method, response, errorData) { constructor(method, response, errorData) {
errorData.error = i18n.t('api_not_found') errorData.error = i18n.t('api_not_found')
super(method, response, errorData) super(method, response, errorData)
this.name = 'APINotFoundError' this.name = 'APINotFoundError'
} }
} }
// 500 — Server Internal Error // 500 — Server Internal Error
class APIInternalError extends APIError { class APIInternalError extends APIError {
constructor (method, response, errorData) { constructor(method, response, errorData) {
super(method, response, errorData) super(method, response, errorData)
this.traceback = errorData.traceback || null this.traceback = errorData.traceback || null
this.name = 'APIInternalError' this.name = 'APIInternalError'
} }
} }
// 502 — Bad gateway (means API is down) // 502 — Bad gateway (means API is down)
class APINotRespondingError extends APIError { class APINotRespondingError extends APIError {
constructor (method, response) { constructor(method, response) {
super(method, response, { error: i18n.t('api_not_responding') }) super(method, response, { error: i18n.t('api_not_responding') })
this.name = 'APINotRespondingError' this.name = 'APINotRespondingError'
} }
} }
// Temp factory // Temp factory
const errors = { const errors = {
[undefined]: APIError, [undefined]: APIError,
@ -101,10 +97,9 @@ const errors = {
401: APIUnauthorizedError, 401: APIUnauthorizedError,
404: APINotFoundError, 404: APINotFoundError,
500: APIInternalError, 500: APIInternalError,
502: APINotRespondingError 502: APINotRespondingError,
} }
export { export {
errors as default, errors as default,
APIError, APIError,
@ -114,5 +109,5 @@ export {
APIInternalError, APIInternalError,
APINotFoundError, APINotFoundError,
APINotRespondingError, APINotRespondingError,
APIUnauthorizedError APIUnauthorizedError,
} }

View file

@ -6,14 +6,13 @@
import store from '@/store' import store from '@/store'
import errors, { APIError } from './errors' import errors, { APIError } from './errors'
/** /**
* Try to get response content as json and if it's not as text. * Try to get response content as json and if it's not as text.
* *
* @param {Response} response - A fetch `Response` object. * @param {Response} response - A fetch `Response` object.
* @return {(Object|String)} Parsed response's json or response's text. * @return {(Object|String)} Parsed response's json or response's text.
*/ */
export async function getResponseData (response) { export async function getResponseData(response) {
// FIXME the api should always return json as response // FIXME the api should always return json as response
const responseText = await response.text() const responseText = await response.text()
try { try {
@ -23,7 +22,6 @@ export async function getResponseData (response) {
} }
} }
/** /**
* Opens a WebSocket connection to the server in case it sends messages. * Opens a WebSocket connection to the server in case it sends messages.
* Currently, the connection is closed by the server right after an API call so * Currently, the connection is closed by the server right after an API call so
@ -33,11 +31,16 @@ export async function getResponseData (response) {
* @param {Object} request - Request info data. * @param {Object} request - Request info data.
* @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event. * @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event.
*/ */
export function openWebSocket (request) { export function openWebSocket(request) {
return new Promise(resolve => { return new Promise((resolve) => {
const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`) const ws = new WebSocket(
`wss://${store.getters.host}/yunohost/api/messages`,
)
ws.onmessage = ({ data }) => { ws.onmessage = ({ data }) => {
store.dispatch('DISPATCH_MESSAGE', { request, messages: JSON.parse(data) }) store.dispatch('DISPATCH_MESSAGE', {
request,
messages: JSON.parse(data),
})
} }
// ws.onclose = (e) => {} // ws.onclose = (e) => {}
ws.onopen = resolve ws.onopen = resolve
@ -46,7 +49,6 @@ export function openWebSocket (request) {
}) })
} }
/** /**
* Handler for API errors. * Handler for API errors.
* *
@ -55,7 +57,7 @@ export function openWebSocket (request) {
* @param {Object|String} errorData - The response parsed json/text. * @param {Object|String} errorData - The response parsed json/text.
* @throws Will throw a `APIError` with request and response data. * @throws Will throw a `APIError` with request and response data.
*/ */
export async function handleError (request, response, errorData) { export async function handleError(request, response, errorData) {
let errorCode = response.status in errors ? response.status : undefined let errorCode = response.status in errors ? response.status : undefined
if (typeof errorData === 'string') { if (typeof errorData === 'string') {
// FIXME API: Patching errors that are plain text or html. // FIXME API: Patching errors that are plain text or html.
@ -70,26 +72,24 @@ export async function handleError (request, response, errorData) {
throw new errors[errorCode](request, response, errorData) throw new errors[errorCode](request, response, errorData)
} }
/** /**
* If an APIError is not catched by a view it will be dispatched to the store so the * If an APIError is not catched by a view it will be dispatched to the store so the
* error can be displayed in the error modal. * error can be displayed in the error modal.
* *
* @param {APIError} error * @param {APIError} error
*/ */
export function onUnhandledAPIError (error) { export function onUnhandledAPIError(error) {
error.log() error.log()
store.dispatch('HANDLE_ERROR', error) store.dispatch('HANDLE_ERROR', error)
} }
/** /**
* Global catching of unhandled promise's rejections. * Global catching of unhandled promise's rejections.
* Those errors (thrown or rejected from inside a promise) can't be catched by * Those errors (thrown or rejected from inside a promise) can't be catched by
* `window.onerror`. * `window.onerror`.
*/ */
export function registerGlobalErrorHandlers () { export function registerGlobalErrorHandlers() {
window.addEventListener('unhandledrejection', e => { window.addEventListener('unhandledrejection', (e) => {
const error = e.reason const error = e.reason
if (error instanceof APIError) { if (error instanceof APIError) {
onUnhandledAPIError(error) onUnhandledAPIError(error)

View file

@ -24,8 +24,16 @@
/> />
</BInputGroupAppend> </BInputGroupAppend>
<span class="sr-only" :id="id + 'local-part-desc'" v-t="'address.local_part_description.' + type" /> <span
<span class="sr-only" :id="id + 'domain-desc'" v-t="'address.domain_description.' + type" /> class="sr-only"
:id="id + 'local-part-desc'"
v-t="'address.local_part_description.' + type"
/>
<span
class="sr-only"
:id="id + 'domain-desc'"
v-t="'address.domain_description.' + type"
/>
</BInputGroup> </BInputGroup>
</template> </template>
@ -42,17 +50,17 @@ export default {
placeholder: { type: String, default: null }, placeholder: { type: String, default: null },
id: { type: String, default: null }, id: { type: String, default: null },
state: { type: null, default: null }, state: { type: null, default: null },
type: { type: String, default: 'email' } type: { type: String, default: 'email' },
}, },
methods: { methods: {
onInput (key, value) { onInput(key, value) {
this.$emit('input', { this.$emit('input', {
...this.value, ...this.value,
[key]: value [key]: value,
}) })
} },
} },
} }
</script> </script>

View file

@ -1,12 +1,13 @@
<template> <template>
<BCard <BCard v-bind="$attrs" no-body :class="_class">
v-bind="$attrs"
no-body :class="_class"
>
<template #header> <template #header>
<slot name="header"> <slot name="header">
<h2> <h2>
<BButton v-b-toggle="id" :variant="variant" class="card-collapse-button"> <BButton
v-b-toggle="id"
:variant="variant"
class="card-collapse-button"
>
{{ title }} {{ title }}
<YIcon class="ml-auto" iname="chevron-right" /> <YIcon class="ml-auto" iname="chevron-right" />
</BButton> </BButton>
@ -29,21 +30,21 @@ export default {
title: { type: String, required: true }, title: { type: String, required: true },
variant: { type: String, default: 'white' }, variant: { type: String, default: 'white' },
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
flush: { type: Boolean, default: false } flush: { type: Boolean, default: false },
}, },
computed: { computed: {
_class () { _class() {
const baseClass = 'card-collapse' const baseClass = 'card-collapse'
return [ return [
baseClass, baseClass,
{ {
[`${baseClass}-flush`]: this.flush, [`${baseClass}-flush`]: this.flush,
[`${baseClass}-${this.variant}`]: this.variant [`${baseClass}-${this.variant}`]: this.variant,
} },
] ]
} },
} },
} }
</script> </script>
@ -57,10 +58,10 @@ export default {
display: flex; display: flex;
width: 100%; width: 100%;
text-align: left; text-align: left;
padding-top: $spacer * .5; padding-top: $spacer * 0.5;
padding-bottom: $spacer * .5; padding-bottom: $spacer * 0.5;
border-radius: 0; border-radius: 0;
font: inherit font: inherit;
} }
&-flush { &-flush {

View file

@ -6,25 +6,30 @@ export default {
name: 'CardDeckFeed', name: 'CardDeckFeed',
props: { props: {
stacks: { type: Number, default: 21 } stacks: { type: Number, default: 21 },
}, },
data () { data() {
return { return {
busy: false, busy: false,
range: this.stacks, range: this.stacks,
childrenCount: this.$slots.default.length childrenCount: this.$slots.default.length,
} }
}, },
methods: { methods: {
getTopParent (prev) { getTopParent(prev) {
return prev.parentElement === this.$refs.feed ? prev : this.getTopParent(prev.parentElement) return prev.parentElement === this.$refs.feed
? prev
: this.getTopParent(prev.parentElement)
}, },
onScroll () { onScroll() {
const elem = this.$refs.feed const elem = this.$refs.feed
if (window.innerHeight > elem.clientHeight + elem.getBoundingClientRect().top - 200) { if (
window.innerHeight >
elem.clientHeight + elem.getBoundingClientRect().top - 200
) {
this.busy = true this.busy = true
this.range = Math.min(this.range + this.stacks, this.childrenCount) this.range = Math.min(this.range + this.stacks, this.childrenCount)
this.$nextTick().then(() => { this.$nextTick().then(() => {
@ -33,7 +38,7 @@ export default {
} }
}, },
onKeydown (e) { onKeydown(e) {
if (['PageUp', 'PageDown'].includes(e.code)) { if (['PageUp', 'PageDown'].includes(e.code)) {
e.preventDefault() e.preventDefault()
const key = e.code === 'PageUp' ? 'previous' : 'next' const key = e.code === 'PageUp' ? 'previous' : 'next'
@ -44,16 +49,16 @@ export default {
} }
} }
// FIXME Add `Home` and `End` shorcuts // FIXME Add `Home` and `End` shorcuts
} },
}, },
mounted () { mounted() {
window.addEventListener('scroll', this.onScroll) window.addEventListener('scroll', this.onScroll)
this.$refs.feed.addEventListener('keydown', this.onKeydown) this.$refs.feed.addEventListener('keydown', this.onKeydown)
this.onScroll() this.onScroll()
}, },
beforeUpdate () { beforeUpdate() {
const slots = this.$slots.default const slots = this.$slots.default
if (this.childrenCount !== slots.length) { if (this.childrenCount !== slots.length) {
this.range = this.stacks this.range = this.stacks
@ -61,21 +66,21 @@ export default {
} }
}, },
render (h) { render(h) {
return h( return h(
'BCardGroup', 'BCardGroup',
{ {
attrs: { role: 'feed', 'aria-busy': this.busy.toString() }, attrs: { role: 'feed', 'aria-busy': this.busy.toString() },
props: { deck: true }, props: { deck: true },
ref: 'feed' ref: 'feed',
}, },
this.$slots.default.slice(0, this.range) this.$slots.default.slice(0, this.range),
) )
}, },
beforeDestroy () { beforeDestroy() {
window.removeEventListener('scroll', this.onScroll) window.removeEventListener('scroll', this.onScroll)
this.$refs.feed.removeEventListener('keydown', this.onKeydown) this.$refs.feed.removeEventListener('keydown', this.onKeydown)
} },
} }
</script> </script>

View file

@ -1,6 +1,10 @@
<template> <template>
<AbstractForm <AbstractForm
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }" v-bind="{
id: panel.id + '-form',
validation,
serverError: panel.serverError,
}"
@submit.prevent.stop="onApply" @submit.prevent.stop="onApply"
:no-footer="!panel.hasApplyButton" :no-footer="!panel.hasApplyButton"
> >
@ -20,15 +24,20 @@
class="panel-section" class="panel-section"
> >
<BCardTitle v-if="section.name" title-tag="h3"> <BCardTitle v-if="section.name" title-tag="h3">
{{ section.name }} <small v-if="section.help">{{ section.help }}</small> {{ section.name }}
<small v-if="section.help">{{ section.help }}</small>
</BCardTitle> </BCardTitle>
<template v-for="(field, fname) in section.fields"> <template v-for="(field, fname) in section.fields">
<!-- FIXME rework the whole component chain to avoid direct mutation of the `forms` props --> <!-- FIXME rework the whole component chain to avoid direct mutation of the `forms` props -->
<!-- eslint-disable --> <!-- eslint-disable -->
<Component <Component
v-if="field.visible" :is="field.is" v-bind="field.props" v-if="field.visible"
v-model="forms[panel.id][fname]" :validation="validation[fname]" :key="fname" :is="field.is"
v-bind="field.props"
v-model="forms[panel.id][fname]"
:validation="validation[fname]"
:key="fname"
@action.stop="onAction(section.id, fname, section.fields)" @action.stop="onAction(section.id, fname, section.fields)"
/> />
<!-- eslint-enable --> <!-- eslint-enable -->
@ -43,7 +52,6 @@
<script> <script>
import { filterObject } from '@/helpers/commons' import { filterObject } from '@/helpers/commons'
export default { export default {
name: 'ConfigPanel', name: 'ConfigPanel',
@ -51,41 +59,43 @@ export default {
tabId: { type: String, required: true }, tabId: { type: String, required: true },
panels: { type: Array, default: undefined }, panels: { type: Array, default: undefined },
forms: { type: Object, default: undefined }, forms: { type: Object, default: undefined },
v: { type: Object, default: undefined } v: { type: Object, default: undefined },
}, },
computed: { computed: {
panel () { panel() {
return this.panels.find(panel => panel.id === this.tabId) return this.panels.find((panel) => panel.id === this.tabId)
}, },
validation () { validation() {
return this.v.forms[this.panel.id] return this.v.forms[this.panel.id]
} },
}, },
methods: { methods: {
onApply () { onApply() {
const panelId = this.panel.id const panelId = this.panel.id
this.$emit('submit', { this.$emit('submit', {
id: panelId, id: panelId,
form: this.forms[panelId] form: this.forms[panelId],
}) })
}, },
onAction (sectionId, actionId, actionFields) { onAction(sectionId, actionId, actionFields) {
const panelId = this.panel.id const panelId = this.panel.id
const actionFieldsKeys = Object.keys(actionFields) const actionFieldsKeys = Object.keys(actionFields)
this.$emit('submit', { this.$emit('submit', {
id: panelId, id: panelId,
form: filterObject(this.forms[panelId], ([key]) => actionFieldsKeys.includes(key)), form: filterObject(this.forms[panelId], ([key]) =>
actionFieldsKeys.includes(key),
),
action: [panelId, sectionId, actionId].join('.'), action: [panelId, sectionId, actionId].join('.'),
name: actionId name: actionId,
}) })
} },
} },
} }
</script> </script>

View file

@ -32,7 +32,7 @@ export default {
name: 'ConfigPanels', name: 'ConfigPanels',
components: { components: {
RoutableTabs: () => import('@/components/RoutableTabs.vue') RoutableTabs: () => import('@/components/RoutableTabs.vue'),
}, },
mixins: [validationMixin], mixins: [validationMixin],
@ -43,28 +43,28 @@ export default {
validations: { type: Object, default: undefined }, validations: { type: Object, default: undefined },
errors: { type: Object, default: undefined }, // never used errors: { type: Object, default: undefined }, // never used
routes: { type: Array, default: null }, routes: { type: Array, default: null },
noRedirect: { type: Boolean, default: false } noRedirect: { type: Boolean, default: false },
}, },
computed: { computed: {
routes_ () { routes_() {
if (this.routes) return this.routes if (this.routes) return this.routes
return this.panels.map(panel => ({ return this.panels.map((panel) => ({
to: { params: { tabId: panel.id } }, to: { params: { tabId: panel.id } },
text: panel.name, text: panel.name,
icon: panel.icon || 'wrench' icon: panel.icon || 'wrench',
})) }))
} },
}, },
validations () { validations() {
return { forms: this.validations } return { forms: this.validations }
}, },
created () { created() {
if (!this.noRedirect && !this.$route.params.tabId) { if (!this.noRedirect && !this.$route.params.tabId) {
this.$router.replace({ params: { tabId: this.panels[0].id } }) this.$router.replace({ params: { tabId: this.panels[0].id } })
} }
} },
} }
</script> </script>

View file

@ -13,58 +13,64 @@ export default {
minHeight: { type: Number, default: 0 }, minHeight: { type: Number, default: 0 },
renderDelay: { type: Number, default: 100 }, renderDelay: { type: Number, default: 100 },
unrenderDelay: { type: Number, default: 2000 }, unrenderDelay: { type: Number, default: 2000 },
rootMargin: { type: String, default: '300px' } rootMargin: { type: String, default: '300px' },
}, },
data () { data() {
return { return {
observer: null, observer: null,
render: false, render: false,
fixedMinHeight: this.minHeight fixedMinHeight: this.minHeight,
} }
}, },
mounted () { mounted() {
let unrenderTimer let unrenderTimer
let renderTimer let renderTimer
this.observer = new IntersectionObserver(entries => { this.observer = new IntersectionObserver(
let intersecting = entries[0].isIntersecting (entries) => {
let intersecting = entries[0].isIntersecting
// Fix for weird bug when typing fast in app search or on slow client. // Fix for weird bug when typing fast in app search or on slow client.
// Intersection is triggered but even if the element is indeed in the viewport, // Intersection is triggered but even if the element is indeed in the viewport,
// isIntersecting is `false`, so we have to manually check this // isIntersecting is `false`, so we have to manually check this
// FIXME Would be great to find out why this is happening // FIXME Would be great to find out why this is happening
if (!intersecting && this.$el.offsetTop < window.innerHeight) { if (!intersecting && this.$el.offsetTop < window.innerHeight) {
intersecting = true intersecting = true
}
if (intersecting) {
clearTimeout(unrenderTimer)
// Show the component after a delay (to avoid rendering while scrolling fast)
renderTimer = setTimeout(() => {
this.render = true
}, this.unrender ? this.renderDelay : 0)
if (!this.unrender) {
// Stop listening to intersections after first appearance if unrendering is not activated
this.observer.disconnect()
} }
} else if (this.unrender) {
clearTimeout(renderTimer) if (intersecting) {
// Hide the component after a delay if it's no longer in the viewport clearTimeout(unrenderTimer)
unrenderTimer = setTimeout(() => { // Show the component after a delay (to avoid rendering while scrolling fast)
this.fixedMinHeight = this.$el.clientHeight renderTimer = setTimeout(
this.render = false () => {
}, this.unrenderDelay) this.render = true
} },
}, { rootMargin: this.rootMargin }) this.unrender ? this.renderDelay : 0,
)
if (!this.unrender) {
// Stop listening to intersections after first appearance if unrendering is not activated
this.observer.disconnect()
}
} else if (this.unrender) {
clearTimeout(renderTimer)
// Hide the component after a delay if it's no longer in the viewport
unrenderTimer = setTimeout(() => {
this.fixedMinHeight = this.$el.clientHeight
this.render = false
}, this.unrenderDelay)
}
},
{ rootMargin: this.rootMargin },
)
this.observer.observe(this.$el) this.observer.observe(this.$el)
}, },
beforeDestroy () { beforeDestroy() {
this.observer.disconnect() this.observer.disconnect()
} },
} }
</script> </script>

View file

@ -1,17 +1,21 @@
<template> <template>
<BListGroup <BListGroup
v-bind="$attrs" flush v-bind="$attrs"
:class="{ 'fixed-height': fixedHeight, 'bordered': bordered }" flush
:class="{ 'fixed-height': fixedHeight, bordered: bordered }"
@scroll="onScroll" @scroll="onScroll"
> >
<YListGroupItem <YListGroupItem
v-if="limit && messages.length > limit" v-if="limit && messages.length > limit"
variant="info" v-t="'api.partial_logs'" variant="info"
v-t="'api.partial_logs'"
/> />
<YListGroupItem <YListGroupItem
v-for="({ color, text }, i) in reducedMessages" :key="i" v-for="({ color, text }, i) in reducedMessages"
:variant="color" size="xs" :key="i"
:variant="color"
size="xs"
> >
<span v-html="text" /> <span v-html="text" />
</YListGroupItem> </YListGroupItem>
@ -27,43 +31,43 @@ export default {
fixedHeight: { type: Boolean, default: false }, fixedHeight: { type: Boolean, default: false },
bordered: { type: Boolean, default: false }, bordered: { type: Boolean, default: false },
autoScroll: { type: Boolean, default: false }, autoScroll: { type: Boolean, default: false },
limit: { type: Number, default: null } limit: { type: Number, default: null },
}, },
data () { data() {
return { return {
auto: true auto: true,
} }
}, },
computed: { computed: {
reducedMessages () { reducedMessages() {
const len = this.messages.length const len = this.messages.length
if (!this.limit || len <= this.limit) { if (!this.limit || len <= this.limit) {
return this.messages return this.messages
} }
return this.messages.slice(len - this.limit) return this.messages.slice(len - this.limit)
} },
}, },
methods: { methods: {
scrollToEnd () { scrollToEnd() {
if (!this.auto) return if (!this.auto) return
this.$nextTick(() => { this.$nextTick(() => {
this.$el.scrollTo(0, this.$el.lastElementChild.offsetTop) this.$el.scrollTo(0, this.$el.lastElementChild.offsetTop)
}) })
}, },
onScroll ({ target }) { onScroll({ target }) {
this.auto = target.scrollHeight === target.scrollTop + target.clientHeight this.auto = target.scrollHeight === target.scrollTop + target.clientHeight
} },
}, },
created () { created() {
if (this.autoScroll) { if (this.autoScroll) {
this.$watch('messages', this.scrollToEnd) this.$watch('messages', this.scrollToEnd)
} }
} },
} }
</script> </script>

View file

@ -1,7 +1,11 @@
<template> <template>
<div class="query-header w-100" v-on="$listeners" v-bind="$attrs"> <div class="query-header w-100" v-on="$listeners" v-bind="$attrs">
<!-- STATUS --> <!-- STATUS -->
<span class="status" :class="['bg-' + color, statusSize]" :aria-label="$t('api.query_status.' + request.status)" /> <span
class="status"
:class="['bg-' + color, statusSize]"
:aria-label="$t('api.query_status.' + request.status)"
/>
<!-- REQUEST DESCRIPTION --> <!-- REQUEST DESCRIPTION -->
<strong class="request-desc"> <strong class="request-desc">
@ -15,14 +19,16 @@
</span> </span>
<!-- WEBSOCKET WARNINGS COUNT --> <!-- WEBSOCKET WARNINGS COUNT -->
<span class="count" v-if="request.warnings"> <span class="count" v-if="request.warnings">
{{ request.warnings }}<YIcon iname="warning" class="text-warning ml-1" /> {{ request.warnings
}}<YIcon iname="warning" class="text-warning ml-1" />
</span> </span>
</div> </div>
<!-- VIEW ERROR BUTTON --> <!-- VIEW ERROR BUTTON -->
<BButton <BButton
v-if="showError && request.error" v-if="showError && request.error"
size="sm" pill size="sm"
pill
class="error-btn ml-auto py-0" class="error-btn ml-auto py-0"
variant="danger" variant="danger"
@click="reviewError" @click="reviewError"
@ -31,7 +37,11 @@
</BButton> </BButton>
<!-- TIME DISPLAY --> <!-- TIME DISPLAY -->
<time v-if="showTime" :datetime="hour(request.date)" :class="request.error ? 'ml-2' : 'ml-auto'"> <time
v-if="showTime"
:datetime="hour(request.date)"
:class="request.error ? 'ml-2' : 'ml-auto'"
>
{{ hour(request.date) }} {{ hour(request.date) }}
</time> </time>
</div> </div>
@ -45,38 +55,40 @@ export default {
request: { type: Object, required: true }, request: { type: Object, required: true },
statusSize: { type: String, default: '' }, statusSize: { type: String, default: '' },
showTime: { type: Boolean, default: false }, showTime: { type: Boolean, default: false },
showError: { type: Boolean, default: false } showError: { type: Boolean, default: false },
}, },
computed: { computed: {
color () { color() {
const statuses = { const statuses = {
pending: 'primary', pending: 'primary',
success: 'success', success: 'success',
warning: 'warning', warning: 'warning',
error: 'danger' error: 'danger',
} }
return statuses[this.request.status] return statuses[this.request.status]
}, },
errorsCount () { errorsCount() {
return this.request.messages.filter(({ type }) => type === 'danger').length return this.request.messages.filter(({ type }) => type === 'danger')
.length
}, },
warningsCount () { warningsCount() {
return this.request.messages.filter(({ type }) => type === 'warning').length return this.request.messages.filter(({ type }) => type === 'warning')
} .length
},
}, },
methods: { methods: {
reviewError () { reviewError() {
this.$store.dispatch('REVIEW_ERROR', this.request) this.$store.dispatch('REVIEW_ERROR', this.request)
}, },
hour (date) { hour(date) {
return new Date(date).toLocaleTimeString() return new Date(date).toLocaleTimeString()
} },
} },
} }
</script> </script>
@ -98,15 +110,15 @@ div {
.status { .status {
display: inline-block; display: inline-block;
border-radius: 50%; border-radius: 50%;
width: .75rem; width: 0.75rem;
min-width: .75rem; min-width: 0.75rem;
height: .75rem; height: 0.75rem;
margin-right: .25rem; margin-right: 0.25rem;
&.lg { &.lg {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
margin-right: .5rem; margin-right: 0.5rem;
} }
} }
@ -118,7 +130,7 @@ time {
.count { .count {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: .5rem; margin-left: 0.5rem;
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
@ -126,5 +138,4 @@ time {
display: none; display: none;
} }
} }
</style> </style>

View file

@ -3,16 +3,20 @@
<template v-for="(node, i) in tree.children"> <template v-for="(node, i) in tree.children">
<BListGroupItem <BListGroupItem
:key="node.id" :key="node.id"
class="list-group-item-action" :class="getClasses(node, i)" class="list-group-item-action"
:class="getClasses(node, i)"
@click="$router.push(node.data.to)" @click="$router.push(node.data.to)"
> >
<slot name="default" v-bind="node" /> <slot name="default" v-bind="node" />
<BButton <BButton
v-if="node.children" v-if="node.children"
size="xs" variant="outline-secondary" size="xs"
:aria-expanded="node.data.opened ? 'true' : 'false'" :aria-controls="'collapse-' + node.id" variant="outline-secondary"
:class="node.data.opened ? 'not-collapsed' : 'collapsed'" class="ml-2" :aria-expanded="node.data.opened ? 'true' : 'false'"
:aria-controls="'collapse-' + node.id"
:class="node.data.opened ? 'not-collapsed' : 'collapsed'"
class="ml-2"
@click.stop="node.data.opened = !node.data.opened" @click.stop="node.data.opened = !node.data.opened"
> >
<span class="sr-only">{{ toggleText }}</span> <span class="sr-only">{{ toggleText }}</span>
@ -21,12 +25,15 @@
</BListGroupItem> </BListGroupItem>
<BCollapse <BCollapse
v-if="node.children" :key="'collapse-' + node.id" v-if="node.children"
v-model="node.data.opened" :id="'collapse-' + node.id" :key="'collapse-' + node.id"
v-model="node.data.opened"
:id="'collapse-' + node.id"
> >
<RecursiveListGroup <RecursiveListGroup
:tree="node" :tree="node"
:last="last !== undefined ? last : i === tree.children.length - 1" flush :last="last !== undefined ? last : i === tree.children.length - 1"
flush
> >
<!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT --> <!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT -->
<template slot="default" slot-scope="scope"> <template slot="default" slot-scope="scope">
@ -46,17 +53,20 @@ export default {
tree: { type: Object, required: true }, tree: { type: Object, required: true },
flush: { type: Boolean, default: false }, flush: { type: Boolean, default: false },
last: { type: Boolean, default: undefined }, last: { type: Boolean, default: undefined },
toggleText: { type: String, default: null } toggleText: { type: String, default: null },
}, },
methods: { methods: {
getClasses (node, i) { getClasses(node, i) {
const children = node.height > 0 const children = node.height > 0
const opened = children && node.data.opened const opened = children && node.data.opened
const last = this.last !== false && (!children || !opened) && i === this.tree.children.length - 1 const last =
this.last !== false &&
(!children || !opened) &&
i === this.tree.children.length - 1
return { collapsible: children, uncollapsible: !children, opened, last } return { collapsible: children, uncollapsible: !children, opened, last }
} },
} },
} }
</script> </script>

View file

@ -3,8 +3,11 @@
<BCardHeader header-tag="nav"> <BCardHeader header-tag="nav">
<BNav card-header fill pills> <BNav card-header fill pills>
<BNavItem <BNavItem
v-for="route in routes" :key="route.text" v-for="route in routes"
:to="route.to" exact exact-active-class="active" :key="route.text"
:to="route.to"
exact
exact-active-class="active"
> >
<YIcon v-if="route.icon" :iname="route.icon" /> <YIcon v-if="route.icon" :iname="route.icon" />
{{ route.text }} {{ route.text }}
@ -36,7 +39,7 @@ export default {
inheritAttrs: false, inheritAttrs: false,
props: { props: {
routes: { type: Array, required: true } routes: { type: Array, required: true },
} },
} }
</script> </script>

View file

@ -4,16 +4,16 @@
<slot name="disclaimer" /> <slot name="disclaimer" />
<BForm <BForm
:id="id" :inline="inline" :class="formClasses" :id="id"
@submit.prevent="onSubmit" novalidate :inline="inline"
:class="formClasses"
@submit.prevent="onSubmit"
novalidate
> >
<slot name="default" /> <slot name="default" />
<slot name="server-error" v-bind="{ errorFeedback }"> <slot name="server-error" v-bind="{ errorFeedback }">
<BAlert <BAlert v-if="errorFeedback" variant="danger" class="my-3" icon="ban">
v-if="errorFeedback"
variant="danger" class="my-3" icon="ban"
>
<div v-html="errorFeedback" /> <div v-html="errorFeedback" />
</BAlert> </BAlert>
</slot> </slot>
@ -41,28 +41,28 @@ 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 },
noFooter: { type: Boolean, default: false } noFooter: { type: Boolean, default: false },
}, },
computed: { computed: {
errorFeedback () { errorFeedback() {
if (this.serverError) return this.serverError if (this.serverError) return this.serverError
else if (this.validation && this.validation.$anyError) { else if (this.validation && this.validation.$anyError) {
return this.$i18n.t('form_errors.invalid_form') return this.$i18n.t('form_errors.invalid_form')
} else return '' } else return ''
} },
}, },
methods: { methods: {
onSubmit (e) { onSubmit(e) {
const v = this.validation const v = this.validation
if (v) { if (v) {
v.$touch() v.$touch()
if (v.$pending || v.$invalid) return if (v.$pending || v.$invalid) return
} }
this.$emit('submit', e) this.$emit('submit', e)
} },
} },
} }
</script> </script>
@ -73,7 +73,7 @@ export default {
align-items: center; align-items: center;
& > *:not(:first-child) { & > *:not(:first-child) {
margin-left: .5rem; margin-left: 0.5rem;
} }
} }
</style> </style>

View file

@ -4,14 +4,19 @@
<slot name="disclaimer" /> <slot name="disclaimer" />
<BForm <BForm
:id="id" :inline="inline" :class="formClasses" :id="id"
@submit.prevent="onSubmit" novalidate :inline="inline"
:class="formClasses"
@submit.prevent="onSubmit"
novalidate
> >
<slot name="default" /> <slot name="default" />
<slot name="server-error"> <slot name="server-error">
<BAlert <BAlert
variant="danger" class="my-3" icon="ban" variant="danger"
class="my-3"
icon="ban"
:show="errorFeedback !== ''" :show="errorFeedback !== ''"
> >
<div v-html="errorFeedback" /> <div v-html="errorFeedback" />
@ -41,30 +46,29 @@ 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 },
noFooter: { type: Boolean, default: false } noFooter: { type: Boolean, default: false },
}, },
computed: { computed: {
errorFeedback () { errorFeedback() {
if (this.serverError) return this.serverError if (this.serverError) return this.serverError
else if (this.validation && this.validation.$anyError) { else if (this.validation && this.validation.$anyError) {
return this.$i18n.t('form_errors.invalid_form') return this.$i18n.t('form_errors.invalid_form')
} else return '' } else return ''
} },
}, },
methods: { methods: {
onSubmit (e) { onSubmit(e) {
const v = this.validation const v = this.validation
if (v) { if (v) {
v.$touch() v.$touch()
if (v.$pending || v.$invalid) return if (v.$pending || v.$invalid) return
} }
this.$emit('submit', e) this.$emit('submit', e)
} },
} },
} }
</script> </script>
<style lang="scss"> <style lang="scss"></style>
</style>

View file

@ -21,21 +21,21 @@ export default {
props: { props: {
term: { type: String, default: null }, term: { type: String, default: null },
details: { type: String, default: null }, details: { type: String, default: null },
cols: { type: Object, default: () => ({ md: 4, xl: 3 }) } cols: { type: Object, default: () => ({ md: 4, xl: 3 }) },
}, },
computed: { computed: {
cols_ () { cols_() {
return Object.assign({ md: 4, xl: 3 }, this.cols) return Object.assign({ md: 4, xl: 3 }, this.cols)
} },
} },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.description-row { .description-row {
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
margin: .25rem 0; margin: 0.25rem 0;
&:hover { &:hover {
background-color: rgba($black, 0.05); background-color: rgba($black, 0.05);
border-radius: 0.2rem; border-radius: 0.2rem;

View file

@ -2,17 +2,19 @@
<span class="explain-what"> <span class="explain-what">
<slot name="default" /> <slot name="default" />
<span class="explain-what-popover-container"> <span class="explain-what-popover-container">
<BButton <BButton :id="id" href="#" variant="light">
:id="id" href="#"
variant="light"
>
<YIcon iname="question" /> <YIcon iname="question" />
<span class="sr-only">{{ $t('details_about', { subject: title }) }}</span> <span class="sr-only">
{{ $t('details_about', { subject: title }) }}
</span>
</BButton> </BButton>
<BPopover <BPopover
placement="auto" placement="auto"
:target="id" triggers="focus" custom-class="explain-what-popover" :target="id"
:variant="variant" :title="title" triggers="focus"
custom-class="explain-what-popover"
:variant="variant"
:title="title"
> >
<span v-html="content" /> <span v-html="content" />
</BPopover> </BPopover>
@ -28,14 +30,14 @@ export default {
id: { type: String, required: true }, id: { type: String, required: true },
title: { type: String, required: true }, title: { type: String, required: true },
content: { type: String, required: true }, content: { type: String, required: true },
variant: { type: String, default: 'info' } variant: { type: String, default: 'info' },
}, },
computed: { computed: {
cols_ () { cols_() {
return Object.assign({ md: 4, xl: 3 }, this.cols) return Object.assign({ md: 4, xl: 3 }, this.cols)
} },
} },
} }
</script> </script>
@ -45,7 +47,7 @@ export default {
.btn { .btn {
padding: 0; padding: 0;
margin-left: .1rem; margin-left: 0.1rem;
border-radius: 50rem; border-radius: 50rem;
line-height: inherit; line-height: inherit;
font-size: inherit; font-size: inherit;

View file

@ -28,18 +28,18 @@
<!-- Render description --> <!-- Render description -->
<template v-if="description || link"> <template v-if="description || link">
<div class="d-flex"> <div class="d-flex">
<BLink <BLink v-if="link" :to="link" :href="link.href" class="ml-auto">
v-if="link"
:to="link" :href="link.href" class="ml-auto"
>
{{ link.text }} {{ link.text }}
</BLink> </BLink>
</div> </div>
<VueShowdown <VueShowdown
v-if="description" v-if="description"
:markdown="description" flavor="github" :markdown="description"
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }" flavor="github"
:class="{
['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant,
}"
/> />
</template> </template>
<!-- Slot available to overwrite the one above --> <!-- Slot available to overwrite the one above -->
@ -64,23 +64,23 @@ export default {
component: { type: String, default: 'InputItem' }, component: { type: String, default: 'InputItem' },
value: { type: null, default: null }, value: { type: null, default: null },
props: { type: Object, default: () => ({}) }, props: { type: Object, default: () => ({}) },
validation: { type: Object, default: null } validation: { type: Object, default: null },
}, },
computed: { computed: {
_id () { _id() {
if (this.id) return this.id if (this.id) return this.id
const childId = this.props.id || this.$attrs['label-for'] const childId = this.props.id || this.$attrs['label-for']
return childId ? childId + '_group' : null return childId ? childId + '_group' : null
}, },
attrs () { attrs() {
const attrs = { ...this.$attrs } const attrs = { ...this.$attrs }
if ('label' in attrs) { if ('label' in attrs) {
const defaultAttrs = { const defaultAttrs = {
'label-cols-md': 4, 'label-cols-md': 4,
'label-cols-lg': 3, 'label-cols-lg': 3,
'label-class': ['font-weight-bold', 'py-0'] 'label-class': ['font-weight-bold', 'py-0'],
} }
if (!('label-cols' in attrs)) { if (!('label-cols' in attrs)) {
for (const attr in defaultAttrs) { for (const attr in defaultAttrs) {
@ -93,7 +93,7 @@ export default {
return attrs return attrs
}, },
state () { state() {
// Need to set state as null if no error, else component turn green // Need to set state as null if no error, else component turn green
if (this.validation) { if (this.validation) {
return this.validation.$anyError === true ? false : null return this.validation.$anyError === true ? false : null
@ -101,18 +101,18 @@ export default {
return null return null
}, },
errorMessage () { errorMessage() {
const validation = this.validation const validation = this.validation
if (validation && validation.$anyError) { if (validation && validation.$anyError) {
const [type, errData] = this.findError(validation.$params, validation) const [type, errData] = this.findError(validation.$params, validation)
return this.$i18n.t('form_errors.' + type, errData) return this.$i18n.t('form_errors.' + type, errData)
} }
return '' return ''
} },
}, },
methods: { methods: {
touch (name) { touch(name) {
if (this.validation) { if (this.validation) {
// For fields that have multiple elements // For fields that have multiple elements
if (name) { if (name) {
@ -123,7 +123,7 @@ export default {
} }
}, },
findError (params, obj, parent = obj) { findError(params, obj, parent = obj) {
for (const key in params) { for (const key in params) {
if (!obj[key]) { if (!obj[key]) {
return [key, obj.$params[key]] return [key, obj.$params[key]]
@ -132,8 +132,8 @@ export default {
return this.findError(obj[key].$params, obj[key], parent) return this.findError(obj[key].$params, obj[key], parent)
} }
} }
} },
} },
} }
</script> </script>

View file

@ -21,32 +21,35 @@ export default {
label: { type: String, required: true }, label: { type: String, required: true },
component: { type: String, default: 'InputItem' }, component: { type: String, default: 'InputItem' },
value: { type: null, default: null }, value: { type: null, default: null },
cols: { type: Object, default: () => ({ md: 4, lg: 3 }) } cols: { type: Object, default: () => ({ md: 4, lg: 3 }) },
}, },
computed: { computed: {
cols_ () { cols_() {
return Object.assign({ md: 4, lg: 3 }, this.cols) return Object.assign({ md: 4, lg: 3 }, this.cols)
}, },
text () { text() {
return this.parseValue(this.value) return this.parseValue(this.value)
} },
}, },
methods: { methods: {
parseValue (value) { parseValue(value) {
const item = this.component const item = this.component
if (item === 'FileItem') value = value.file ? value.file.name : null if (item === 'FileItem') value = value.file ? value.file.name : null
if (item === 'CheckboxItem') value = this.$i18n.t(value ? 'yes' : 'no') if (item === 'CheckboxItem') value = this.$i18n.t(value ? 'yes' : 'no')
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>') if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
if (Array.isArray(value)) { if (Array.isArray(value)) {
value = value.length ? value.join(this.$i18n.t('words.separator')) : null value = value.length
? value.join(this.$i18n.t('words.separator'))
: null
} }
if ([null, undefined, ''].includes(this.value)) value = this.$i18n.t('words.none') if ([null, undefined, ''].includes(this.value))
value = this.$i18n.t('words.none')
return value return value
} },
} },
} }
</script> </script>

View file

@ -23,25 +23,25 @@ export default {
button: { button: {
type: Object, type: Object,
default: null, default: null,
validator (value) { validator(value) {
return ['text', 'to'].every(prop => (prop in value)) return ['text', 'to'].every((prop) => prop in value)
} },
} },
}, },
data () { data() {
return { return {
hasLeftSlot: null, hasLeftSlot: null,
hasRightSlot: null hasRightSlot: null,
} }
}, },
created () { created() {
this.$nextTick(() => { this.$nextTick(() => {
this.hasLeftSlot = 'group-left' in this.$slots this.hasLeftSlot = 'group-left' in this.$slots
this.hasRightSlot = 'group-right' in this.$slots this.hasRightSlot = 'group-right' in this.$slots
}) })
} },
} }
</script> </script>
@ -65,10 +65,10 @@ export default {
flex-direction: column-reverse; flex-direction: column-reverse;
#top-bar-right { #top-bar-right {
margin-bottom: .75rem; margin-bottom: 0.75rem;
::v-deep > * { ::v-deep > * {
margin-bottom: .25rem; margin-bottom: 0.25rem;
} }
} }
@ -89,7 +89,7 @@ export default {
} }
::v-deep .btn { ::v-deep .btn {
margin-left: .5rem; margin-left: 0.5rem;
&.dropdown-toggle-split { &.dropdown-toggle-split {
margin-left: 0; margin-left: 0;
} }

View file

@ -40,44 +40,46 @@ export default {
queriesWait: { type: Boolean, default: false }, queriesWait: { type: Boolean, default: false },
skeleton: { type: [String, Array], default: null }, skeleton: { type: [String, Array], default: null },
// Optional prop to take control of the loading value // Optional prop to take control of the loading value
loading: { type: Boolean, default: null } loading: { type: Boolean, default: null },
}, },
data () { data() {
return { return {
fallback_loading: this.loading === null && this.queries !== null ? true : null fallback_loading:
this.loading === null && this.queries !== null ? true : null,
} }
}, },
computed: { computed: {
isLoading () { isLoading() {
if (this.loading !== null) return this.loading if (this.loading !== null) return this.loading
return this.fallback_loading return this.fallback_loading
}, },
hasTopBar () { hasTopBar() {
return ['top-bar-group-left', 'top-bar-group-right'].some(slotName => (slotName in this.$slots)) return ['top-bar-group-left', 'top-bar-group-right'].some(
} (slotName) => slotName in this.$slots,
)
},
}, },
methods: { methods: {
fetchQueries ({ triggerLoading = false } = {}) { fetchQueries({ triggerLoading = false } = {}) {
if (triggerLoading) { if (triggerLoading) {
this.fallback_loading = true this.fallback_loading = true
} }
api.fetchAll( api
this.queries, .fetchAll(this.queries, { wait: this.queriesWait, initial: true })
{ wait: this.queriesWait, initial: true } .then((responses) => {
).then(responses => { this.$emit('queries-response', ...responses)
this.$emit('queries-response', ...responses) this.fallback_loading = false
this.fallback_loading = false })
}) },
}
}, },
created () { created() {
if (this.queries) this.fetchQueries() if (this.queries) this.fetchQueries()
} },
} }
</script> </script>

View file

@ -11,8 +11,11 @@
<BFormInput <BFormInput
id="top-bar-search" id="top-bar-search"
:value="search" @input="$emit('update:search', $event)" :value="search"
:placeholder="$t('search.for', { items: $tc('items.' + itemsName, 2) })" @input="$emit('update:search', $event)"
:placeholder="
$t('search.for', { items: $tc('items.' + itemsName, 2) })
"
:disabled="!items" :disabled="!items"
/> />
</BInputGroup> </BInputGroup>
@ -29,7 +32,13 @@
<BAlert v-if="items === null || filteredItems === null" variant="warning"> <BAlert v-if="items === null || filteredItems === null" variant="warning">
<slot name="alert-message"> <slot name="alert-message">
<YIcon iname="exclamation-triangle" /> <YIcon iname="exclamation-triangle" />
{{ $tc(items === null ? 'items_verbose_count': 'search.not_found', 0, { items: $tc('items.' + itemsName, 0) }) }} {{
$tc(
items === null ? 'items_verbose_count' : 'search.not_found',
0,
{ items: $tc('items.' + itemsName, 0) },
)
}}
</slot> </slot>
</BAlert> </BAlert>
@ -55,13 +64,13 @@ export default {
itemsName: { type: String, required: true }, itemsName: { type: String, required: true },
filteredItems: { type: null, required: true }, filteredItems: { type: null, required: true },
search: { type: String, default: null }, search: { type: String, default: null },
skeleton: { type: String, default: 'ListGroupSkeleton' } skeleton: { type: String, default: 'ListGroupSkeleton' },
}, },
computed: { computed: {
hasCustomTopBar () { hasCustomTopBar() {
return 'top-bar' in this.$slots return 'top-bar' in this.$slots
} },
} },
} }
</script> </script>

View file

@ -23,14 +23,14 @@ export default {
props: { props: {
alert: { type: Boolean, default: false }, alert: { type: Boolean, default: false },
variant: { type: String, default: 'info' }, variant: { type: String, default: 'info' },
icon: { type: String, default: null } icon: { type: String, default: null },
}, },
computed: { computed: {
_icon () { _icon() {
if (this.icon) return this.icon if (this.icon) return this.icon
return DEFAULT_STATUS_ICON[this.variant] return DEFAULT_STATUS_ICON[this.variant]
} },
} },
} }
</script> </script>

View file

@ -6,8 +6,10 @@
</BBreadcrumbItem> </BBreadcrumbItem>
<BBreadcrumbItem <BBreadcrumbItem
v-for="({ name, text }, i) in breadcrumb" :key="name" v-for="({ name, text }, i) in breadcrumb"
:to="{ name }" :active="i === breadcrumb.length - 1" :key="name"
:to="{ name }"
:active="i === breadcrumb.length - 1"
> >
{{ text }} {{ text }}
</BBreadcrumbItem> </BBreadcrumbItem>
@ -21,8 +23,8 @@ export default {
name: 'YBreadcrumb', name: 'YBreadcrumb',
computed: { computed: {
...mapGetters(['breadcrumb']) ...mapGetters(['breadcrumb']),
} },
} }
</script> </script>

View file

@ -9,15 +9,29 @@
<slot name="header-next" /> <slot name="header-next" />
</slot> </slot>
<div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }"> <div
v-if="hasButtons"
class="mt-2 w-100 custom-header-buttons"
:class="{
[`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
buttonUnbreak,
}"
>
<slot name="header-buttons" /> <slot name="header-buttons" />
</div> </div>
</div> </div>
<BButton <BButton
v-if="collapsable" @click="visible = !visible" v-if="collapsable"
size="sm" variant="outline-secondary" @click="visible = !visible"
class="align-self-center ml-auto" :class="{ 'not-collapsed': visible, 'collapsed': !visible, [`ml-${buttonUnbreak}-2`]: buttonUnbreak }" size="sm"
variant="outline-secondary"
class="align-self-center ml-auto"
:class="{
'not-collapsed': visible,
collapsed: !visible,
[`ml-${buttonUnbreak}-2`]: buttonUnbreak,
}"
> >
<YIcon iname="chevron-right" /> <YIcon iname="chevron-right" />
<span class="sr-only">{{ $t('words.collapse') }}</span> <span class="sr-only">{{ $t('words.collapse') }}</span>
@ -25,7 +39,7 @@
</template> </template>
<BCollapse v-if="collapsable" :visible="visible"> <BCollapse v-if="collapsable" :visible="visible">
<slot v-if="('no-body' in $attrs)" name="default" /> <slot v-if="'no-body' in $attrs" name="default" />
<BCardBody v-else> <BCardBody v-else>
<slot name="default" /> <slot name="default" />
</BCardBody> </BCardBody>
@ -41,7 +55,6 @@
</template> </template>
<script> <script>
export default { export default {
name: 'YCard', name: 'YCard',
@ -52,20 +65,20 @@ export default {
icon: { type: String, default: null }, icon: { type: String, default: null },
collapsable: { type: Boolean, default: false }, collapsable: { type: Boolean, default: false },
collapsed: { type: Boolean, default: false }, collapsed: { type: Boolean, default: false },
buttonUnbreak: { type: String, default: 'md' } buttonUnbreak: { type: String, default: 'md' },
}, },
data () { data() {
return { return {
visible: !this.collapsed visible: !this.collapsed,
} }
}, },
computed: { computed: {
hasButtons () { hasButtons() {
return 'header-buttons' in this.$slots return 'header-buttons' in this.$slots
} },
} },
} }
</script> </script>
@ -79,7 +92,7 @@ export default {
} }
.btn + .btn { .btn + .btn {
margin-left: .5rem; margin-left: 0.5rem;
} }
} }
} }
@ -90,7 +103,7 @@ export default {
align-items: center; align-items: center;
& > *:not(:first-child) { & > *:not(:first-child) {
margin-left: .5rem; margin-left: 0.5rem;
} }
} }
.collapse:not(.show) + .card-footer { .collapse:not(.show) + .card-footer {

View file

@ -1,5 +1,8 @@
<template> <template>
<span :class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']" aria-hidden="true" /> <span
:class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']"
aria-hidden="true"
/>
</template> </template>
<script> <script>
@ -7,8 +10,8 @@ export default {
name: 'YIcon', name: 'YIcon',
props: { props: {
iname: { type: String, required: true }, iname: { type: String, required: true },
variant: { type: String, default: null } variant: { type: String, default: null },
} },
} }
</script> </script>
@ -35,7 +38,7 @@ export default {
} }
&.variant { &.variant {
font-size: .8rem; font-size: 0.8rem;
width: 1.35rem; width: 1.35rem;
min-width: 1.35rem; min-width: 1.35rem;
height: 1.35rem; height: 1.35rem;

View file

@ -1,13 +1,7 @@
<template> <template>
<BListGroupItem <BListGroupItem class="yuno-list-group-item" :class="_class" v-bind="$attrs">
class="yuno-list-group-item" :class="_class"
v-bind="$attrs"
>
<div v-if="!noStatus" class="yuno-list-group-item-status"> <div v-if="!noStatus" class="yuno-list-group-item-status">
<YIcon <YIcon v-if="_icon" :iname="_icon" :class="['icon-' + variant]" />
v-if="_icon" :iname="_icon"
:class="['icon-' + variant]"
/>
</div> </div>
<div class="yuno-list-group-item-content"> <div class="yuno-list-group-item-content">
@ -28,28 +22,27 @@ export default {
noIcon: { type: Boolean, default: false }, noIcon: { type: Boolean, default: false },
noStatus: { type: Boolean, default: false }, noStatus: { type: Boolean, default: false },
size: { type: String, default: 'md' }, size: { type: String, default: 'md' },
faded: { type: Boolean, default: false } faded: { type: Boolean, default: false },
}, },
computed: { computed: {
_icon () { _icon() {
return this.noIcon ? null : this.icon || DEFAULT_STATUS_ICON[this.variant] return this.noIcon ? null : this.icon || DEFAULT_STATUS_ICON[this.variant]
}, },
_class () { _class() {
const baseClass = 'yuno-list-group-item-' const baseClass = 'yuno-list-group-item-'
return [ return [
baseClass + this.size, baseClass + this.size,
baseClass + this.variant, baseClass + this.variant,
{ [baseClass + 'faded']: this.faded } { [baseClass + 'faded']: this.faded },
] ]
} },
} },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.yuno-list-group-item { .yuno-list-group-item {
display: flex; display: flex;
padding: 0; padding: 0;
@ -70,7 +63,7 @@ export default {
&-#{$color} { &-#{$color} {
color: theme-color-level($color, 6); color: theme-color-level($color, 6);
[dark-theme="true"] & { [dark-theme='true'] & {
color: theme-color-level($color, -6); color: theme-color-level($color, -6);
} }
@ -96,7 +89,7 @@ export default {
&-xs { &-xs {
.yuno-list-group-item-status { .yuno-list-group-item-status {
width: .4rem; width: 0.4rem;
.icon { .icon {
display: none; display: none;
@ -109,7 +102,7 @@ export default {
} }
&-faded > * { &-faded > * {
opacity: .5; opacity: 0.5;
} }
} }
</style> </style>

View file

@ -5,13 +5,12 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
export default { export default {
name: 'YSpinner', name: 'YSpinner',
computed: { computed: {
...mapGetters(['spinner']) ...mapGetters(['spinner']),
} },
} }
</script> </script>
@ -26,15 +25,28 @@ export default {
background-image: url('../../assets/spinners/pacman_dark.gif'); background-image: url('../../assets/spinners/pacman_dark.gif');
animation-name: back-and-forth-pacman; animation-name: back-and-forth-pacman;
[dark-theme="true"] & { [dark-theme='true'] & {
background-image: url('../../assets/spinners/pacman_light.gif'); background-image: url('../../assets/spinners/pacman_light.gif');
} }
@keyframes back-and-forth-pacman { @keyframes back-and-forth-pacman {
0%, 100% { transform: scale(1); margin-left: 0; } 0%,
49% { transform: scale(1); margin-left: calc(100% - 24px);} 100% {
50% { transform: scale(-1); margin-left: calc(100% - 24px);} transform: scale(1);
99% { transform: scale(-1); margin-left: 0;} margin-left: 0;
}
49% {
transform: scale(1);
margin-left: calc(100% - 24px);
}
50% {
transform: scale(-1);
margin-left: calc(100% - 24px);
}
99% {
transform: scale(-1);
margin-left: 0;
}
} }
} }
@ -45,10 +57,23 @@ export default {
animation-name: back-and-forth-magikarp; animation-name: back-and-forth-magikarp;
@keyframes back-and-forth-magikarp { @keyframes back-and-forth-magikarp {
0%, 100% { transform: scale(1, 1); margin-left: 0; } 0%,
49% { transform: scale(1, 1); margin-left: calc(100% - 32px);} 100% {
50% { transform: scale(-1, 1); margin-left: calc(100% - 32px);} transform: scale(1, 1);
99% { transform: scale(-1, 1); margin-left: 0;} margin-left: 0;
}
49% {
transform: scale(1, 1);
margin-left: calc(100% - 32px);
}
50% {
transform: scale(-1, 1);
margin-left: calc(100% - 32px);
}
99% {
transform: scale(-1, 1);
margin-left: 0;
}
} }
} }
@ -59,10 +84,23 @@ export default {
animation-name: back-and-forth-nyancat; animation-name: back-and-forth-nyancat;
@keyframes back-and-forth-nyancat { @keyframes back-and-forth-nyancat {
0%, 100% { transform: scale(1, 1); margin-left: 0; } 0%,
49% { transform: scale(1, 1); margin-left: calc(100% - 100px);} 100% {
50% { transform: scale(-1, 1); margin-left: calc(100% - 100px);} transform: scale(1, 1);
99% { transform: scale(-1, 1); margin-left: 0;} margin-left: 0;
}
49% {
transform: scale(1, 1);
margin-left: calc(100% - 100px);
}
50% {
transform: scale(-1, 1);
margin-left: calc(100% - 100px);
}
99% {
transform: scale(-1, 1);
margin-left: 0;
}
} }
} }
@ -73,10 +111,23 @@ export default {
animation-name: back-and-forth-spookycat; animation-name: back-and-forth-spookycat;
@keyframes back-and-forth-spookycat { @keyframes back-and-forth-spookycat {
0%, 100% { transform: scale(1, 1); margin-left: 0; } 0%,
49% { transform: scale(1, 1); margin-left: calc(100% - 100px);} 100% {
50% { transform: scale(-1, 1); margin-left: calc(100% - 100px);} transform: scale(1, 1);
99% { transform: scale(-1, 1); margin-left: 0;} margin-left: 0;
}
49% {
transform: scale(1, 1);
margin-left: calc(100% - 100px);
}
50% {
transform: scale(-1, 1);
margin-left: calc(100% - 100px);
}
99% {
transform: scale(-1, 1);
margin-left: 0;
}
} }
} }
} }

View file

@ -12,7 +12,6 @@
</template> </template>
<script> <script>
export default { export default {
name: 'ButtonItem', name: 'ButtonItem',
@ -21,20 +20,20 @@ export default {
id: { type: String, default: null }, id: { type: String, default: null },
type: { type: String, default: 'success' }, type: { type: String, default: 'success' },
icon: { type: String, default: null }, icon: { type: String, default: null },
enabled: { type: [Boolean, String], default: true } enabled: { type: [Boolean, String], default: true },
}, },
computed: { computed: {
icon_ () { icon_() {
const icons = { const icons = {
success: 'thumbs-up', success: 'thumbs-up',
info: 'info', info: 'info',
warning: 'exclamation', warning: 'exclamation',
danger: 'times' danger: 'times',
} }
return this.icon || icons[this.type] return this.icon || icons[this.type]
} },
} },
} }
</script> </script>

View file

@ -18,13 +18,13 @@ export default {
value: { type: Boolean, required: true }, value: { type: Boolean, required: true },
id: { type: String, default: null }, id: { type: String, default: null },
label: { type: String, default: null }, label: { type: String, default: null },
labels: { type: Object, default: () => ({ true: 'yes', false: 'no' }) } labels: { type: Object, default: () => ({ true: 'yes', false: 'no' }) },
}, },
data () { data() {
return { return {
checked: this.value checked: this.value,
} }
} },
} }
</script> </script>

View file

@ -10,7 +10,7 @@ export default {
props: { props: {
id: { type: String, default: null }, id: { type: String, default: null },
label: { type: String, default: null } label: { type: String, default: null },
} },
} }
</script> </script>

View file

@ -2,7 +2,8 @@
<BButtonGroup class="w-100"> <BButtonGroup class="w-100">
<BButton <BButton
v-if="!this.required && this.value.file !== null" v-if="!this.required && this.value.file !== null"
@click="clearFiles" variant="danger" @click="clearFiles"
variant="danger"
> >
<span class="sr-only">{{ $t('delete') }}</span> <span class="sr-only">{{ $t('delete') }}</span>
<YIcon iname="trash" /> <YIcon iname="trash" />
@ -39,42 +40,42 @@ export default {
accept: { type: String, default: null }, accept: { type: String, default: null },
state: { type: Boolean, default: null }, state: { type: Boolean, default: null },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },
name: { type: String, default: null } name: { type: String, default: null },
}, },
computed: { computed: {
_placeholder: function () { _placeholder: function () {
return this.value.file === null ? this.placeholder : this.value.file.name return this.value.file === null ? this.placeholder : this.value.file.name
} },
}, },
methods: { methods: {
onInput (file) { onInput(file) {
const value = { const value = {
file, file,
content: '', content: '',
current: false, current: false,
removed: false removed: false,
} }
// Update the value with the new File and an empty content for now // Update the value with the new File and an empty content for now
this.$emit('input', value) this.$emit('input', value)
// Asynchronously load the File content and update the value again // Asynchronously load the File content and update the value again
getFileContent(file).then(content => { getFileContent(file).then((content) => {
this.$emit('input', { ...value, content }) this.$emit('input', { ...value, content })
}) })
}, },
clearFiles () { clearFiles() {
this.$refs['input-file'].reset() this.$refs['input-file'].reset()
this.$emit('input', { this.$emit('input', {
file: null, file: null,
content: '', content: '',
current: false, current: false,
removed: true removed: true,
}) })
} },
} },
} }
</script> </script>

View file

@ -17,7 +17,6 @@
</template> </template>
<script> <script>
export default { export default {
name: 'InputItem', name: 'InputItem',
@ -34,13 +33,17 @@ export default {
trim: { type: Boolean, default: true }, trim: { type: Boolean, default: true },
autocomplete: { type: String, default: null }, autocomplete: { type: String, default: null },
pattern: { type: Object, default: null }, pattern: { type: Object, default: null },
name: { type: String, default: null } name: { type: String, default: null },
}, },
data () { data() {
return { return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null autocomplete_: this.autocomplete
? this.autocomplete
: this.type === 'password'
? 'new-password'
: null,
} }
} },
} }
</script> </script>

View file

@ -8,7 +8,7 @@ export default {
props: { props: {
id: { type: String, default: null }, id: { type: String, default: null },
label: { type: String, default: null } label: { type: String, default: null },
} },
} }
</script> </script>

View file

@ -1,10 +1,16 @@
<template> <template>
<BAlert class="d-flex flex-column flex-md-row align-items-center" :variant="type" show> <BAlert
class="d-flex flex-column flex-md-row align-items-center"
:variant="type"
show
>
<YIcon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" /> <YIcon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" />
<VueShowdown <VueShowdown
:markdown="label" flavor="github" :markdown="label"
tag="span" class="markdown" flavor="github"
tag="span"
class="markdown"
/> />
</BAlert> </BAlert>
</template> </template>
@ -17,19 +23,19 @@ export default {
id: { type: String, default: null }, id: { type: String, default: null },
label: { type: String, default: null }, label: { type: String, default: null },
type: { type: String, default: null }, type: { type: String, default: null },
icon: { type: String, default: null } icon: { type: String, default: null },
}, },
computed: { computed: {
icon_ () { icon_() {
const icons = { const icons = {
success: 'thumbs-up', success: 'thumbs-up',
info: 'info', info: 'info',
warning: 'exclamation', warning: 'exclamation',
danger: 'times' danger: 'times',
} }
return this.icon || icons[this.type] return this.icon || icons[this.type]
} },
} },
} }
</script> </script>

View file

@ -18,7 +18,7 @@ export default {
id: { type: String, default: null }, id: { type: String, default: null },
choices: { type: [Array, Object], required: true }, choices: { type: [Array, Object], required: true },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },
name: { type: String, default: null } name: { type: String, default: null },
} },
} }
</script> </script>

View file

@ -17,9 +17,9 @@
export default { export default {
name: 'TagsItem', name: 'TagsItem',
data () { data() {
return { return {
tags: this.value tags: this.value,
} }
}, },
props: { props: {
@ -29,8 +29,7 @@ export default {
limit: { type: Number, default: null }, limit: { type: Number, default: null },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },
state: { type: Boolean, default: null }, state: { type: Boolean, default: null },
name: { type: String, default: null } name: { type: String, default: null },
} },
} }
</script> </script>

View file

@ -1,13 +1,24 @@
<template> <template>
<div class="tags-selectize"> <div class="tags-selectize">
<BFormTags <BFormTags
v-bind="$attrs" v-on="$listeners" v-bind="$attrs"
:value="value" :id="id" v-on="$listeners"
size="lg" class="p-0 border-0" no-outer-focus :value="value"
:id="id"
size="lg"
class="p-0 border-0"
no-outer-focus
> >
<template #default="{ tags, disabled, addTag, removeTag }"> <template #default="{ tags, disabled, addTag, removeTag }">
<ul v-if="!noTags && tags.length > 0" class="list-inline d-inline-block mb-2"> <ul
<li v-for="tag in tags" :key="id + '-' + tag" class="list-inline-item"> v-if="!noTags && tags.length > 0"
class="list-inline d-inline-block mb-2"
>
<li
v-for="tag in tags"
:key="id + '-' + tag"
class="list-inline-item"
>
<BFormTag <BFormTag
@remove="onRemoveTag({ option: tag, removeTag })" @remove="onRemoveTag({ option: tag, removeTag })"
:title="tag" :title="tag"
@ -21,7 +32,9 @@
<BDropdown <BDropdown
ref="dropdown" ref="dropdown"
variant="outline-dark" block menu-class="w-100" variant="outline-dark"
block
menu-class="w-100"
@keydown.native="onDropdownKeydown" @keydown.native="onDropdownKeydown"
> >
<template #button-content> <template #button-content>
@ -32,15 +45,25 @@
<BDropdownForm @submit.stop.prevent="() => {}"> <BDropdownForm @submit.stop.prevent="() => {}">
<BFormGroup <BFormGroup
:label="$t('search.for', { items: itemsName })" :label="$t('search.for', { items: itemsName })"
label-cols-md="auto" label-size="sm" :label-for="id + '-search-input'" label-cols-md="auto"
:invalid-feedback="$tc('search.not_found', 0, { items: $tc('items.' + itemsName, 0) })" label-size="sm"
:state="searchState" :disabled="disabled" :label-for="id + '-search-input'"
:invalid-feedback="
$tc('search.not_found', 0, {
items: $tc('items.' + itemsName, 0),
})
"
:state="searchState"
:disabled="disabled"
class="mb-0" class="mb-0"
> >
<BFormInput <BFormInput
ref="search-input" v-model="search" ref="search-input"
v-model="search"
:id="id + '-search-input'" :id="id + '-search-input'"
type="search" size="sm" autocomplete="off" type="search"
size="sm"
autocomplete="off"
/> />
</BFormGroup> </BFormGroup>
</BDropdownForm> </BDropdownForm>
@ -56,7 +79,11 @@
</BDropdownItemButton> </BDropdownItemButton>
<BDropdownText v-if="!criteria && availableOptions.length === 0"> <BDropdownText v-if="!criteria && availableOptions.length === 0">
<YIcon iname="exclamation-triangle" /> <YIcon iname="exclamation-triangle" />
{{ $tc('items_verbose_items_left', 0, { items: $tc('items.' + itemsName, 0) }) }} {{
$tc('items_verbose_items_left', 0, {
items: $tc('items.' + itemsName, 0),
})
}}
</BDropdownText> </BDropdownText>
</BDropdown> </BDropdown>
</template> </template>
@ -76,43 +103,45 @@ export default {
limit: { type: Number, default: null }, limit: { type: Number, default: null },
name: { type: String, default: null }, name: { type: String, default: null },
itemsName: { type: String, required: true }, itemsName: { type: String, required: true },
disabledItems: { type: Array, default: () => ([]) }, disabledItems: { type: Array, default: () => [] },
// By default `addTag` and `removeTag` have to be executed manually by listening to 'tag-update'. // By default `addTag` and `removeTag` have to be executed manually by listening to 'tag-update'.
auto: { type: Boolean, default: false }, auto: { type: Boolean, default: false },
noTags: { type: Boolean, default: false }, noTags: { type: Boolean, default: false },
label: { type: String, default: null }, label: { type: String, default: null },
tagIcon: { type: String, default: null } tagIcon: { type: String, default: null },
}, },
data () { data() {
return { return {
search: '' search: '',
} }
}, },
computed: { computed: {
criteria () { criteria() {
return this.search.trim().toLowerCase() return this.search.trim().toLowerCase()
}, },
availableOptions () { availableOptions() {
const criteria = this.criteria const criteria = this.criteria
const options = this.options.filter(opt => { const options = this.options.filter((opt) => {
return this.value.indexOf(opt) === -1 && !this.disabledItems.includes(opt) return (
this.value.indexOf(opt) === -1 && !this.disabledItems.includes(opt)
)
}) })
if (criteria) { if (criteria) {
return options.filter(opt => opt.toLowerCase().indexOf(criteria) > -1) return options.filter((opt) => opt.toLowerCase().indexOf(criteria) > -1)
} }
return options return options
}, },
searchState () { searchState() {
return this.criteria && this.availableOptions.length === 0 ? false : null return this.criteria && this.availableOptions.length === 0 ? false : null
} },
}, },
methods: { methods: {
onAddTag ({ option, addTag }) { onAddTag({ option, addTag }) {
this.$emit('tag-update', { action: 'add', option, applyMethod: addTag }) this.$emit('tag-update', { action: 'add', option, applyMethod: addTag })
this.search = '' this.search = ''
if (this.auto) { if (this.auto) {
@ -120,14 +149,18 @@ export default {
} }
}, },
onRemoveTag ({ option, removeTag }) { onRemoveTag({ option, removeTag }) {
this.$emit('tag-update', { action: 'remove', option, applyMethod: removeTag }) this.$emit('tag-update', {
action: 'remove',
option,
applyMethod: removeTag,
})
if (this.auto) { if (this.auto) {
removeTag(option) removeTag(option)
} }
}, },
onDropdownKeydown (e) { onDropdownKeydown(e) {
// Allow to start searching after dropdown opening // Allow to start searching after dropdown opening
if ( if (
!['Tab', 'Space'].includes(e.code) && !['Tab', 'Space'].includes(e.code) &&
@ -135,8 +168,8 @@ export default {
) { ) {
this.$refs['search-input'].focus() this.$refs['search-input'].focus()
} }
} },
} },
} }
</script> </script>
@ -147,7 +180,7 @@ export default {
padding-top: 0; padding-top: 0;
.search-group { .search-group {
padding-top: .5rem; padding-top: 0.5rem;
position: sticky; position: sticky;
top: 0; top: 0;
background-color: $white; background-color: $white;

View file

@ -22,7 +22,7 @@ export default {
type: { type: String, default: 'text' }, type: { type: String, default: 'text' },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },
state: { type: Boolean, default: null }, state: { type: Boolean, default: null },
name: { type: String, default: null } name: { type: String, default: null },
} },
} }
</script> </script>

View file

@ -12,7 +12,7 @@
<BSkeleton v-else :width="randint(45, 100) + '%'" height="24px" /> <BSkeleton v-else :width="randint(45, 100) + '%'" height="24px" />
<BSkeleton :width="randint(20, 30) + '%'" height="38px" class="mt-3" /> <BSkeleton :width="randint(20, 30) + '%'" height="38px" class="mt-3" />
<hr> <hr />
</div> </div>
</BCard> </BCard>
</template> </template>
@ -24,9 +24,9 @@ export default {
name: 'CardButtonsSkeleton', name: 'CardButtonsSkeleton',
props: { props: {
itemCount: { type: Number, default: 5 } itemCount: { type: Number, default: 5 },
}, },
methods: { randint } methods: { randint },
} }
</script> </script>

View file

@ -8,12 +8,19 @@
<BRow :key="count" :class="{ 'd-block': cols === null }"> <BRow :key="count" :class="{ 'd-block': cols === null }">
<BCol v-bind="cols"> <BCol v-bind="cols">
<div style="height: 38px" class="d-flex align-items-center"> <div style="height: 38px" class="d-flex align-items-center">
<BSkeleton class="m-0" :width="randint(45, 100) + '%'" height="24px" /> <BSkeleton
class="m-0"
:width="randint(45, 100) + '%'"
height="24px"
/>
</div> </div>
</BCol> </BCol>
<BCol> <BCol>
<div class="w100 d-flex justify-content-between" v-if="count % 2 === 0"> <div
class="w100 d-flex justify-content-between"
v-if="count % 2 === 0"
>
<BSkeleton width="100%" height="38px" /> <BSkeleton width="100%" height="38px" />
<BSkeleton width="38px" height="38px" class="ml-2" /> <BSkeleton width="38px" height="38px" class="ml-2" />
@ -25,7 +32,7 @@
</BCol> </BCol>
</BRow> </BRow>
<hr :key="count + '-hr'"> <hr :key="count + '-hr'" />
</template> </template>
<template #footer> <template #footer>
@ -44,9 +51,14 @@ export default {
props: { props: {
itemCount: { type: Number, default: 5 }, itemCount: { type: Number, default: 5 },
cols: { type: [Object, null], default () { return { md: 4, lg: 2 } } } cols: {
type: [Object, null],
default() {
return { md: 4, lg: 2 }
},
},
}, },
methods: { randint } methods: { randint },
} }
</script> </script>

View file

@ -22,9 +22,9 @@ export default {
name: 'CardInfoSkeleton', name: 'CardInfoSkeleton',
props: { props: {
itemCount: { type: Number, default: 5 } itemCount: { type: Number, default: 5 },
}, },
methods: { randint } methods: { randint },
} }
</script> </script>

View file

@ -6,8 +6,12 @@
<BListGroup flush> <BListGroup flush>
<BListGroupItem v-for="count in itemCount" :key="count" class="d-flex"> <BListGroupItem v-for="count in itemCount" :key="count" class="d-flex">
<div style="width: 20%;"> <div style="width: 20%">
<BSkeleton :width="randint(50, 100) + '%'" height="24px" class="mr-3" /> <BSkeleton
:width="randint(50, 100) + '%'"
height="24px"
class="mr-3"
/>
</div> </div>
<BSkeleton :width="randint(30, 80) + '%'" height="24px" class="m-0" /> <BSkeleton :width="randint(30, 80) + '%'" height="24px" class="m-0" />
</BListGroupItem> </BListGroupItem>
@ -22,9 +26,9 @@ export default {
name: 'CardListSkeleton', name: 'CardListSkeleton',
props: { props: {
itemCount: { type: Number, default: 5 } itemCount: { type: Number, default: 5 },
}, },
methods: { randint } methods: { randint },
} }
</script> </script>

View file

@ -14,9 +14,9 @@ export default {
name: 'ListGroupSkeleton', name: 'ListGroupSkeleton',
props: { props: {
itemCount: { type: Number, default: 5 } itemCount: { type: Number, default: 5 },
}, },
methods: { randint } methods: { randint },
} }
</script> </script>

View file

@ -7,7 +7,7 @@
* @param {Number} delay - delay after which the promise is rejected * @param {Number} delay - delay after which the promise is rejected
* @return {Promise} * @return {Promise}
*/ */
export function timeout (promise, delay) { export function timeout(promise, delay) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// FIXME reject(new Error('api_not_responding')) for post-install // FIXME reject(new Error('api_not_responding')) for post-install
setTimeout(() => reject, delay) setTimeout(() => reject, delay)
@ -15,18 +15,20 @@ export function timeout (promise, delay) {
}) })
} }
/** /**
* Check if passed value is an object literal. * Check if passed value is an object literal.
* *
* @param {*} value - Anything. * @param {*} value - Anything.
* @return {Boolean} * @return {Boolean}
*/ */
export function isObjectLiteral (value) { export function isObjectLiteral(value) {
return value !== null && value !== undefined && Object.is(value.constructor, Object) return (
value !== null &&
value !== undefined &&
Object.is(value.constructor, Object)
)
} }
/** /**
* Check if value is "empty" (`null`, `undefined`, `''`, `[]`, '{}'). * Check if value is "empty" (`null`, `undefined`, `''`, `[]`, '{}').
* Note: `0` is not considered "empty" in that helper. * Note: `0` is not considered "empty" in that helper.
@ -34,12 +36,11 @@ export function isObjectLiteral (value) {
* @param {*} value - Anything. * @param {*} value - Anything.
* @return {Boolean} * @return {Boolean}
*/ */
export function isEmptyValue (value) { export function isEmptyValue(value) {
if (typeof value === 'number') return false if (typeof value === 'number') return false
return !value || value.length === 0 || Object.keys(value).length === 0 return !value || value.length === 0 || Object.keys(value).length === 0
} }
/** /**
* Returns an flattened object literal, with all keys at first level and removing nested ones. * Returns an flattened object literal, with all keys at first level and removing nested ones.
* *
@ -47,8 +48,8 @@ export function isEmptyValue (value) {
* @param {Object} [flattened={}] - An object literal to add passed obj keys/values. * @param {Object} [flattened={}] - An object literal to add passed obj keys/values.
* @return {Object} * @return {Object}
*/ */
export function flattenObjectLiteral (obj, flattened = {}) { export function flattenObjectLiteral(obj, flattened = {}) {
function flatten (objLit) { function flatten(objLit) {
for (const key in objLit) { for (const key in objLit) {
const value = objLit[key] const value = objLit[key]
if (isObjectLiteral(value)) { if (isObjectLiteral(value)) {
@ -62,7 +63,6 @@ export function flattenObjectLiteral (obj, flattened = {}) {
return flattened return flattened
} }
/** /**
* Returns an new Object filtered with passed filter function. * Returns an new Object filtered with passed filter function.
* Each entry `[key, value]` will be forwarded to the `filter` function. * Each entry `[key, value]` will be forwarded to the `filter` function.
@ -71,11 +71,12 @@ export function flattenObjectLiteral (obj, flattened = {}) {
* @param {Function} filter - the filter function to call for each entry. * @param {Function} filter - the filter function to call for each entry.
* @return {Object} * @return {Object}
*/ */
export function filterObject (obj, filter) { export function filterObject(obj, filter) {
return Object.fromEntries(Object.entries(obj).filter((...args) => filter(...args))) return Object.fromEntries(
Object.entries(obj).filter((...args) => filter(...args)),
)
} }
/** /**
* Returns an new array containing items that are in first array but not in the other. * Returns an new array containing items that are in first array but not in the other.
* *
@ -83,18 +84,17 @@ export function filterObject (obj, filter) {
* @param {Array} [arr2=[]] * @param {Array} [arr2=[]]
* @return {Array} * @return {Array}
*/ */
export function arrayDiff (arr1 = [], arr2 = []) { export function arrayDiff(arr1 = [], arr2 = []) {
return arr1.filter(item => !arr2.includes(item)) return arr1.filter((item) => !arr2.includes(item))
} }
/** /**
* Returns a new string with escaped HTML (`&<>"'` replaced by entities). * Returns a new string with escaped HTML (`&<>"'` replaced by entities).
* *
* @param {String} unsafe * @param {String} unsafe
* @return {String} * @return {String}
*/ */
export function escapeHtml (unsafe) { export function escapeHtml(unsafe) {
return unsafe return unsafe
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
@ -110,11 +110,10 @@ export function escapeHtml (unsafe) {
* @param {Number} max * @param {Number} max
* @return {Number} * @return {Number}
*/ */
export function randint (min, max) { export function randint(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min return Math.floor(Math.random() * (max - min + 1)) + min
} }
/** /**
* Returns a File content. * Returns a File content.
* *
@ -123,7 +122,7 @@ export function randint (min, max) {
* @param {Boolean} [extraParams.base64] - returns a base64 representation of the file. * @param {Boolean} [extraParams.base64] - returns a base64 representation of the file.
* @return {Promise<String>} * @return {Promise<String>}
*/ */
export function getFileContent (file, { base64 = false } = {}) { export function getFileContent(file, { base64 = false } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onerror = reject reader.onerror = reject

View file

@ -2,7 +2,7 @@
* A Node that can have a parent and children. * A Node that can have a parent and children.
*/ */
export class Node { export class Node {
constructor (data) { constructor(data) {
this.data = data this.data = data
this.depth = 0 this.depth = 0
this.height = 0 this.height = 0
@ -22,7 +22,7 @@ export class Node {
* @param {function} callback * @param {function} callback
* @return {Object} * @return {Object}
*/ */
eachBefore (callback) { eachBefore(callback) {
const nodes = [] const nodes = []
let index = -1 let index = -1
let node = this let node = this
@ -49,7 +49,7 @@ export class Node {
* @param {function} callback * @param {function} callback
* @return {Object} * @return {Object}
*/ */
eachAfter (callback) { eachAfter(callback) {
const nodes = [] const nodes = []
const next = [] const next = []
let node = this let node = this
@ -81,7 +81,7 @@ export class Node {
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity. * @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node} * @return {Node}
*/ */
filter (callback) { filter(callback) {
// Duplicates this tree and iter on nodes from leaves to root (post-order traversal) // Duplicates this tree and iter on nodes from leaves to root (post-order traversal)
return hierarchy(this).eachAfter((node, i) => { return hierarchy(this).eachAfter((node, i) => {
// Since we create a new hierarchy from another, nodes's `data` contains the // Since we create a new hierarchy from another, nodes's `data` contains the
@ -90,7 +90,7 @@ export class Node {
if (node.children) { if (node.children) {
// Removed flagged children // Removed flagged children
node.children = node.children.filter(child => !child.remove) node.children = node.children.filter((child) => !child.remove)
if (!node.children.length) delete node.children if (!node.children.length) delete node.children
} }
@ -104,7 +104,6 @@ export class Node {
} }
} }
/** /**
* Generates a new hierarchy from the specified tabular `dataset`. * Generates a new hierarchy from the specified tabular `dataset`.
* The specified `dataset` must be an array of objects that contains at least a * The specified `dataset` must be an array of objects that contains at least a
@ -117,13 +116,16 @@ export class Node {
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity. * @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node} * @return {Node}
*/ */
export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } = {}) { export function stratify(
dataset,
{ idKey = 'name', parentIdKey = 'parent' } = {},
) {
const root = new Node(null, true) const root = new Node(null, true)
root.children = [] root.children = []
const nodesMap = new Map() const nodesMap = new Map()
// Creates all nodes that will be arranged in a hierarchy // Creates all nodes that will be arranged in a hierarchy
const nodes = dataset.map(d => { const nodes = dataset.map((d) => {
const node = new Node(d) const node = new Node(d)
node.id = d[idKey] node.id = d[idKey]
nodesMap.set(node.id, node) nodesMap.set(node.id, node)
@ -148,7 +150,7 @@ export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } =
} }
}) })
root.eachBefore(node => { root.eachBefore((node) => {
// Compute node depth // Compute node depth
if (node.parent) { if (node.parent) {
node.depth = node.parent.depth + 1 node.depth = node.parent.depth + 1
@ -160,7 +162,6 @@ export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } =
return root return root
} }
/** /**
* Constructs a root node from the specified hierarchical `data`. * Constructs a root node from the specified hierarchical `data`.
* The specified `data` must be an object representing the root node and its children. * The specified `data` must be an object representing the root node and its children.
@ -170,14 +171,14 @@ export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } =
* @param {Node|Object} data - object representing a root node (a simple { id, children } object or a `Node`) * @param {Node|Object} data - object representing a root node (a simple { id, children } object or a `Node`)
* @return {Node} * @return {Node}
*/ */
export function hierarchy (data) { export function hierarchy(data) {
const root = new Node(data) const root = new Node(data)
const nodes = [] const nodes = []
let node = root let node = root
while (node) { while (node) {
if (node.data.children) { if (node.data.children) {
node.children = node.data.children.map(child_ => { node.children = node.data.children.map((child_) => {
const child = new Node(child_) const child = new Node(child_)
child.id = child_.id child.id = child_.id
child.parent = node === root ? null : node child.parent = node === root ? null : node
@ -193,14 +194,13 @@ export function hierarchy (data) {
return root return root
} }
/** /**
* Compute the node height by iterating on parents * Compute the node height by iterating on parents
* Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62. * Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62.
* *
* @param {Node} node * @param {Node} node
*/ */
function computeNodeHeight (node) { function computeNodeHeight(node) {
let height = 0 let height = 0
do { do {
node.height = height node.height = height

View file

@ -3,17 +3,13 @@ import format from 'date-fns/format'
import { dateFnsLocale as locale } from '@/i18n/helpers' import { dateFnsLocale as locale } from '@/i18n/helpers'
export function distanceToNow (date, addSuffix = true, isTimestamp = false) { export function distanceToNow(date, addSuffix = true, isTimestamp = false) {
return formatDistanceToNow( return formatDistanceToNow(new Date(isTimestamp ? date * 1000 : date), {
new Date(isTimestamp ? date * 1000 : date), addSuffix,
{ addSuffix, locale } locale,
) })
} }
export function readableDate (date, isTimestamp = false) { export function readableDate(date, isTimestamp = false) {
return format( return format(new Date(isTimestamp ? date * 1000 : date), 'PPPpp', { locale })
new Date(isTimestamp ? date * 1000 : date),
'PPPpp',
{ locale }
)
} }

View file

@ -1,13 +1,15 @@
export function humanSize (bytes) { export function humanSize(bytes) {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
if (bytes === 0) return 'n/a' if (bytes === 0) return 'n/a'
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))) const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)))
return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i] return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i]
} }
export function humanPermissionName(text) {
export function humanPermissionName (text) { return text
return text.split('.')[1].replace('_', ' ').replace(/\w\S*/g, part => { .split('.')[1]
return part.charAt(0).toUpperCase() + part.substr(1).toLowerCase() .replace('_', ' ')
}) .replace(/\w\S*/g, (part) => {
return part.charAt(0).toUpperCase() + part.substr(1).toLowerCase()
})
} }

View file

@ -1,52 +1,70 @@
import { helpers } from 'vuelidate/lib/validators' import { helpers } from 'vuelidate/lib/validators'
// Unicode ranges are taken from https://stackoverflow.com/a/37668315 // Unicode ranges are taken from https://stackoverflow.com/a/37668315
const nonAsciiWordCharacters = '\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FD5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AD\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC' const nonAsciiWordCharacters =
'\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FD5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AD\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC'
const alphalownumdot_ = helpers.regex('alphalownumdot_', /^[a-z0-9_.]+$/) const alphalownumdot_ = helpers.regex('alphalownumdot_', /^[a-z0-9_.]+$/)
const domain = helpers.regex('domain', new RegExp(`^(?:[\\da-z${nonAsciiWordCharacters}]+(?:-*[\\da-z${nonAsciiWordCharacters}]+)*\\.)+(?:(?:xn--)?[\\da-z${nonAsciiWordCharacters}]{2,})$`)) const domain = helpers.regex(
'domain',
new RegExp(
`^(?:[\\da-z${nonAsciiWordCharacters}]+(?:-*[\\da-z${nonAsciiWordCharacters}]+)*\\.)+(?:(?:xn--)?[\\da-z${nonAsciiWordCharacters}]{2,})$`,
),
)
const dynDomain = helpers.regex('dynDomain', new RegExp(`^(?:xn--)?[\\da-z-${nonAsciiWordCharacters}]+$`)) const dynDomain = helpers.regex(
'dynDomain',
new RegExp(`^(?:xn--)?[\\da-z-${nonAsciiWordCharacters}]+$`),
)
const emailLocalPart = helpers.regex('emailLocalPart', /^[\w.-]+$/) const emailLocalPart = helpers.regex('emailLocalPart', /^[\w.-]+$/)
const emailForwardLocalPart = helpers.regex('emailForwardLocalPart', /^[\w+.-]+$/) const emailForwardLocalPart = helpers.regex(
'emailForwardLocalPart',
/^[\w+.-]+$/,
)
const email = value => helpers.withParams( const email = (value) =>
{ type: 'email', value }, helpers.withParams({ type: 'email', value }, (value) => {
value => {
const [localPart, domainPart] = value.split('@') const [localPart, domainPart] = value.split('@')
if (!domainPart) return !helpers.req(value) || false if (!domainPart) return !helpers.req(value) || false
return !helpers.req(value) || (emailLocalPart(localPart) && domain(domainPart)) return (
} !helpers.req(value) || (emailLocalPart(localPart) && domain(domainPart))
)(value) )
})(value)
// Same as email but with `+` allowed. // Same as email but with `+` allowed.
const emailForward = value => helpers.withParams( const emailForward = (value) =>
{ type: 'emailForward', value }, helpers.withParams({ type: 'emailForward', value }, (value) => {
value => {
const [localPart, domainPart] = value.split('@') const [localPart, domainPart] = value.split('@')
if (!domainPart) return !helpers.req(value) || false if (!domainPart) return !helpers.req(value) || false
return !helpers.req(value) || (emailForwardLocalPart(localPart) && domain(domainPart)) return (
} !helpers.req(value) ||
)(value) (emailForwardLocalPart(localPart) && domain(domainPart))
)
})(value)
const appRepoUrl = helpers.regex('appRepoUrl', /^https:\/\/[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_./~]+\/[a-zA-Z0-9-_.]+_ynh(\/?(-\/)?tree\/[a-zA-Z0-9-_.]+)?(\.git)?\/?$/) const appRepoUrl = helpers.regex(
'appRepoUrl',
/^https:\/\/[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_./~]+\/[a-zA-Z0-9-_.]+_ynh(\/?(-\/)?tree\/[a-zA-Z0-9-_.]+)?(\.git)?\/?$/,
)
const includes = items => item => helpers.withParams( const includes = (items) => (item) =>
{ type: 'includes', value: item }, helpers.withParams(
item => !helpers.req(item) || (items ? items.includes(item) : false) { type: 'includes', value: item },
)(item) (item) => !helpers.req(item) || (items ? items.includes(item) : false),
)(item)
const name = helpers.regex('name', new RegExp(`^(?:[A-Za-z${nonAsciiWordCharacters}]{1,30}[ ,.'-]{0,3})+$`)) const name = helpers.regex(
'name',
new RegExp(`^(?:[A-Za-z${nonAsciiWordCharacters}]{1,30}[ ,.'-]{0,3})+$`),
)
const unique = items => item => helpers.withParams( const unique = (items) => (item) =>
{ type: 'unique', arg: items, value: item }, helpers.withParams({ type: 'unique', arg: items, value: item }, (item) =>
item => items ? !helpers.req(item) || !items.includes(item) : true items ? !helpers.req(item) || !items.includes(item) : true,
)(item) )(item)
export { export {
alphalownumdot_, alphalownumdot_,
@ -59,5 +77,5 @@ export {
appRepoUrl, appRepoUrl,
includes, includes,
name, name,
unique unique,
} }

View file

@ -8,5 +8,5 @@ export {
minLength, minLength,
minValue, minValue,
required, required,
sameAs sameAs,
} from 'vuelidate/lib/validators' } from 'vuelidate/lib/validators'

View file

@ -6,16 +6,15 @@ import {
isObjectLiteral, isObjectLiteral,
isEmptyValue, isEmptyValue,
flattenObjectLiteral, flattenObjectLiteral,
getFileContent getFileContent,
} from '@/helpers/commons' } from '@/helpers/commons'
const NO_VALUE_FIELDS = [ const NO_VALUE_FIELDS = [
'ReadOnlyField', 'ReadOnlyField',
'ReadOnlyAlertItem', 'ReadOnlyAlertItem',
'MarkdownItem', 'MarkdownItem',
'DisplayTextItem', 'DisplayTextItem',
'ButtonItem' 'ButtonItem',
] ]
export const DEFAULT_STATUS_ICON = { export const DEFAULT_STATUS_ICON = {
@ -24,7 +23,7 @@ export const DEFAULT_STATUS_ICON = {
error: 'times', error: 'times',
info: 'info', info: 'info',
success: 'check', success: 'check',
warning: 'warning' warning: 'warning',
} }
/** /**
@ -34,20 +33,19 @@ export const DEFAULT_STATUS_ICON = {
* @param {(Object|String|undefined)} field - A field value containing a translation object or string * @param {(Object|String|undefined)} field - A field value containing a translation object or string
* @return {String} * @return {String}
*/ */
export function formatI18nField (field) { export function formatI18nField(field) {
if (typeof field === 'string') return field if (typeof field === 'string') return field
const { locale, fallbackLocale } = store.state const { locale, fallbackLocale } = store.state
return field ? field[locale] || field[fallbackLocale] || field.en : '' return field ? field[locale] || field[fallbackLocale] || field.en : ''
} }
/** /**
* Returns a string size declaration to a M value. * Returns a string size declaration to a M value.
* *
* @param {String} sizeStr - A size declared like '500M' or '56k' * @param {String} sizeStr - A size declared like '500M' or '56k'
* @return {Number} * @return {Number}
*/ */
export function sizeToM (sizeStr) { export function sizeToM(sizeStr) {
const unit = sizeStr.slice(-1) const unit = sizeStr.slice(-1)
const value = sizeStr.slice(0, -1) const value = sizeStr.slice(0, -1)
if (unit === 'M') return parseInt(value) if (unit === 'M') return parseInt(value)
@ -57,20 +55,18 @@ export function sizeToM (sizeStr) {
if (unit === 'T') return Math.ceil(value * 1024 * 1024) if (unit === 'T') return Math.ceil(value * 1024 * 1024)
} }
/** /**
* Returns a formatted address element to be used by AdressInputSelect component. * Returns a formatted address element to be used by AdressInputSelect component.
* *
* @param {String} address - A string representing an adress (subdomain or email) * @param {String} address - A string representing an adress (subdomain or email)
* @return {Object} - `{ localPart, separator, domain }`. * @return {Object} - `{ localPart, separator, domain }`.
*/ */
export function adressToFormValue (address) { export function adressToFormValue(address) {
const separator = address.includes('@') ? '@' : '.' const separator = address.includes('@') ? '@' : '.'
const [localPart, domain] = address.split(separator) const [localPart, domain] = address.split(separator)
return { localPart, separator, domain } return { localPart, separator, domain }
} }
/** /**
* Evaluate config panel string expression that can contain regular expressions. * Evaluate config panel string expression that can contain regular expressions.
* Expression are evaluated with the config panel form as context. * Expression are evaluated with the config panel form as context.
@ -79,7 +75,7 @@ export function adressToFormValue (address) {
* @param {Object} forms - A nested form used in config panels. * @param {Object} forms - A nested form used in config panels.
* @return {Boolean} - expression evaluation result. * @return {Boolean} - expression evaluation result.
*/ */
export function evaluateExpression (expression, form, nested = true) { export function evaluateExpression(expression, form, nested = true) {
if (!expression) return true if (!expression) return true
if (expression === '"false"') return false if (expression === '"false"') return false
@ -110,13 +106,12 @@ export function evaluateExpression (expression, form, nested = true) {
} }
// Adds a property to an Object that will dynamically returns a expression evaluation result. // Adds a property to an Object that will dynamically returns a expression evaluation result.
function addEvaluationGetter (prop, obj, expr, ctx, nested) { function addEvaluationGetter(prop, obj, expr, ctx, nested) {
Object.defineProperty(obj, prop, { Object.defineProperty(obj, prop, {
get: () => evaluateExpression(expr, ctx, nested) get: () => evaluateExpression(expr, ctx, nested),
}) })
} }
/** /**
* Format app install, actions and config panel argument into a data structure that * Format app install, actions and config panel argument into a data structure that
* will be automaticly transformed into a component on screen. * will be automaticly transformed into a component on screen.
@ -124,8 +119,13 @@ function addEvaluationGetter (prop, obj, expr, ctx, nested) {
* @param {Object} arg - a yunohost arg options written by a packager. * @param {Object} arg - a yunohost arg options written by a packager.
* @return {Object} an formated argument containing formItem props, validation and base value. * @return {Object} an formated argument containing formItem props, validation and base value.
*/ */
export function formatYunoHostArgument (arg) { export function formatYunoHostArgument(arg) {
let value = (arg.value !== undefined) ? arg.value : (arg.current_value !== undefined) ? arg.current_value : null let value =
arg.value !== undefined
? arg.value
: arg.current_value !== undefined
? arg.current_value
: null
const validation = {} const validation = {}
const error = { message: null } const error = { message: null }
arg.ask = formatI18nField(arg.ask) arg.ask = formatI18nField(arg.ask)
@ -135,8 +135,8 @@ export function formatYunoHostArgument (arg) {
props: { props: {
label: arg.ask, label: arg.ask,
component: undefined, component: undefined,
props: {} props: {},
} },
} }
const defaultProps = ['id', 'placeholder:example'] const defaultProps = ['id', 'placeholder:example']
@ -144,12 +144,12 @@ export function formatYunoHostArgument (arg) {
{ {
types: ['string', 'path'], types: ['string', 'path'],
name: 'InputItem', name: 'InputItem',
props: defaultProps.concat(['autocomplete', 'trim', 'choices']) props: defaultProps.concat(['autocomplete', 'trim', 'choices']),
}, },
{ {
types: ['email', 'url', 'date', 'time', 'color'], types: ['email', 'url', 'date', 'time', 'color'],
name: 'InputItem', name: 'InputItem',
props: defaultProps.concat(['type', 'trim']) props: defaultProps.concat(['type', 'trim']),
}, },
{ {
types: ['password'], types: ['password'],
@ -161,7 +161,7 @@ export function formatYunoHostArgument (arg) {
} }
arg.example = '••••••••••••' arg.example = '••••••••••••'
validation.passwordLenght = validators.minLength(8) validation.passwordLenght = validators.minLength(8)
} },
}, },
{ {
types: ['number', 'range'], types: ['number', 'range'],
@ -175,7 +175,7 @@ export function formatYunoHostArgument (arg) {
validation.maxValue = validators.maxValue(parseInt(arg.max)) validation.maxValue = validators.maxValue(parseInt(arg.max))
} }
validation.numValue = validators.integer validation.numValue = validators.integer
} },
}, },
{ {
types: ['select', 'user', 'domain', 'app', 'group'], types: ['select', 'user', 'domain', 'app', 'group'],
@ -183,9 +183,12 @@ export function formatYunoHostArgument (arg) {
props: ['id', 'choices'], props: ['id', 'choices'],
callback: function () { callback: function () {
if (arg.type !== 'select') { if (arg.type !== 'select') {
field.props.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) } field.props.link = {
name: arg.type + '-list',
text: i18n.t(`manage_${arg.type}s`),
}
} }
} },
}, },
{ {
types: ['file'], types: ['file'],
@ -197,26 +200,31 @@ export function formatYunoHostArgument (arg) {
file: value ? new File([''], value) : null, file: value ? new File([''], value) : null,
content: '', content: '',
current: !!value, current: !!value,
removed: false removed: false,
} }
} },
}, },
{ {
types: ['text'], types: ['text'],
name: 'TextAreaItem', name: 'TextAreaItem',
props: defaultProps props: defaultProps,
}, },
{ {
types: ['tags'], types: ['tags'],
name: 'TagsItem', name: 'TagsItem',
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']), props: defaultProps.concat([
'limit',
'placeholder',
'options:choices',
'tagIcon:icon',
]),
callback: function () { callback: function () {
if (arg.choices && arg.choices.length) { if (arg.choices && arg.choices.length) {
this.name = 'TagsSelectizeItem' this.name = 'TagsSelectizeItem'
Object.assign(field.props.props, { Object.assign(field.props.props, {
auto: true, auto: true,
itemsName: '', itemsName: '',
label: arg.placeholder label: arg.placeholder,
}) })
} }
if (typeof value === 'string') { if (typeof value === 'string') {
@ -224,7 +232,7 @@ export function formatYunoHostArgument (arg) {
} else if (!value) { } else if (!value) {
value = [] value = []
} }
} },
}, },
{ {
types: ['boolean'], types: ['boolean'],
@ -232,36 +240,40 @@ export function formatYunoHostArgument (arg) {
props: ['id', 'choices'], props: ['id', 'choices'],
callback: function () { callback: function () {
if (value !== null && value !== undefined) { if (value !== null && value !== undefined) {
value = ['1', 'yes', 'y', 'true'].includes(String(value).toLowerCase()) value = ['1', 'yes', 'y', 'true'].includes(
String(value).toLowerCase(),
)
} else if (arg.default !== null && arg.default !== undefined) { } else if (arg.default !== null && arg.default !== undefined) {
value = ['1', 'yes', 'y', 'true'].includes(String(arg.default).toLowerCase()) value = ['1', 'yes', 'y', 'true'].includes(
String(arg.default).toLowerCase(),
)
} }
} },
}, },
{ {
types: ['alert'], types: ['alert'],
name: 'ReadOnlyAlertItem', name: 'ReadOnlyAlertItem',
props: ['type:style', 'label:ask', 'icon'], props: ['type:style', 'label:ask', 'icon'],
renderSelf: true renderSelf: true,
}, },
{ {
types: ['markdown'], types: ['markdown'],
name: 'MarkdownItem', name: 'MarkdownItem',
props: ['label:ask'], props: ['label:ask'],
renderSelf: true renderSelf: true,
}, },
{ {
types: ['display_text'], types: ['display_text'],
name: 'DisplayTextItem', name: 'DisplayTextItem',
props: ['label:ask'], props: ['label:ask'],
renderSelf: true renderSelf: true,
}, },
{ {
types: ['button'], types: ['button'],
name: 'ButtonItem', name: 'ButtonItem',
props: ['type:style', 'label:ask', 'icon', 'enabled'], props: ['type:style', 'label:ask', 'icon', 'enabled'],
renderSelf: true renderSelf: true,
} },
] ]
// Default type management if no one is filled // Default type management if no one is filled
@ -273,7 +285,9 @@ export function formatYunoHostArgument (arg) {
} }
// Search the component bind to the type // Search the component bind to the type
const component = components.find(element => element.types.includes(arg.type)) const component = components.find((element) =>
element.types.includes(arg.type),
)
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type) if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
// Callback use for specific behaviour // Callback use for specific behaviour
@ -290,11 +304,18 @@ export function formatYunoHostArgument (arg) {
} }
// Required (no need for checkbox its value can't be null) // Required (no need for checkbox its value can't be null)
if (!component.renderSelf && arg.type !== 'boolean' && arg.optional !== true) { if (
!component.renderSelf &&
arg.type !== 'boolean' &&
arg.optional !== true
) {
validation.required = validators.required validation.required = validators.required
} }
if (arg.pattern && arg.type !== 'tags') { if (arg.pattern && arg.type !== 'tags') {
validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp)) validation.pattern = validators.helpers.regex(
formatI18nField(arg.pattern.error),
new RegExp(arg.pattern.regexp),
)
} }
if (!component.renderSelf && !arg.readonly) { if (!component.renderSelf && !arg.readonly) {
@ -321,7 +342,10 @@ export function formatYunoHostArgument (arg) {
// Help message // Help message
if (arg.helpLink) { if (arg.helpLink) {
field.props.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) } field.props.link = {
href: arg.helpLink.href,
text: i18n.t(arg.helpLink.text),
}
} }
if (component.renderSelf) { if (component.renderSelf) {
@ -334,11 +358,10 @@ export function formatYunoHostArgument (arg) {
field, field,
// Return null instead of empty object if there's no validation // Return null instead of empty object if there's no validation
validation: Object.keys(validation).length === 0 ? null : validation, validation: Object.keys(validation).length === 0 ? null : validation,
error error,
} }
} }
/** /**
* Format app install, actions and config panel manifest args into a form that can be used * Format app install, actions and config panel manifest args into a form that can be used
* as v-model values, fields that can be passed to a FormField component and validations. * as v-model values, fields that can be passed to a FormField component and validations.
@ -347,7 +370,7 @@ export function formatYunoHostArgument (arg) {
* @param {Object|null} forms - nested form used as the expression evualuations context. * @param {Object|null} forms - nested form used as the expression evualuations context.
* @return {Object} an object containing all parsed values to be used in vue views. * @return {Object} an object containing all parsed values to be used in vue views.
*/ */
export function formatYunoHostArguments (args, forms) { export function formatYunoHostArguments(args, forms) {
const form = {} const form = {}
const fields = {} const fields = {}
const validations = {} const validations = {}
@ -361,28 +384,44 @@ export function formatYunoHostArguments (args, forms) {
errors[arg.id] = error errors[arg.id] = error
if ('visible' in arg && typeof arg.visible === 'string') { if ('visible' in arg && typeof arg.visible === 'string') {
addEvaluationGetter('visible', field, arg.visible, forms || form, forms !== undefined) addEvaluationGetter(
'visible',
field,
arg.visible,
forms || form,
forms !== undefined,
)
} }
if ('enabled' in arg && typeof arg.enabled === 'string') { if ('enabled' in arg && typeof arg.enabled === 'string') {
addEvaluationGetter('enabled', field.props, arg.enabled, forms || form, forms !== undefined) addEvaluationGetter(
'enabled',
field.props,
arg.enabled,
forms || form,
forms !== undefined,
)
} }
} }
return { form, fields, validations, errors } return { form, fields, validations, errors }
} }
export function formatYunoHostConfigPanels(data) {
export function formatYunoHostConfigPanels (data) {
const result = { const result = {
panels: [], panels: [],
forms: {}, forms: {},
validations: {}, validations: {},
errors: {} errors: {},
} }
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: [], serverError: '', hasApplyButton: false } const panel = {
id: panelId,
sections: [],
serverError: '',
hasApplyButton: false,
}
result.forms[panelId] = {} result.forms[panelId] = {}
result.validations[panelId] = {} result.validations[panelId] = {}
result.errors[panelId] = {} result.errors[panelId] = {}
@ -394,7 +433,7 @@ export function formatYunoHostConfigPanels (data) {
const section = { const section = {
id: _section.id, id: _section.id,
isActionSection: _section.is_action_section, isActionSection: _section.is_action_section,
visible: [undefined, true, '"true"'].includes(_section.visible) visible: [undefined, true, '"true"'].includes(_section.visible),
} }
if (_section.help) section.help = formatI18nField(_section.help) if (_section.help) section.help = formatI18nField(_section.help)
if (_section.name) section.name = formatI18nField(_section.name) if (_section.name) section.name = formatI18nField(_section.name)
@ -402,12 +441,10 @@ export function formatYunoHostConfigPanels (data) {
addEvaluationGetter('visible', section, _section.visible, result.forms) addEvaluationGetter('visible', section, _section.visible, result.forms)
} }
const { const { form, fields, validations, errors } = formatYunoHostArguments(
form, _section.options,
fields, result.forms,
validations, )
errors
} = formatYunoHostArguments(_section.options, result.forms)
// Merge all sections forms to the panel to get a unique form // Merge all sections forms to the panel to get a unique form
Object.assign(result.forms[panelId], form) Object.assign(result.forms[panelId], form)
Object.assign(result.validations[panelId], validations) Object.assign(result.validations[panelId], validations)
@ -415,7 +452,12 @@ export function formatYunoHostConfigPanels (data) {
section.fields = fields section.fields = fields
panel.sections.push(section) panel.sections.push(section)
if (!section.isActionSection && Object.values(fields).some((field) => !NO_VALUE_FIELDS.includes(field.is))) { if (
!section.isActionSection &&
Object.values(fields).some(
(field) => !NO_VALUE_FIELDS.includes(field.is),
)
) {
panel.hasApplyButton = true panel.hasApplyButton = true
} }
} }
@ -426,7 +468,6 @@ export function formatYunoHostConfigPanels (data) {
return result return result
} }
/** /**
* Parse a front-end value to its API equivalent. This function returns a Promise or an * Parse a front-end value to its API equivalent. This function returns a Promise or an
* Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those * Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those
@ -439,11 +480,11 @@ export function formatYunoHostConfigPanels (data) {
* @param {*} value * @param {*} value
* @return {*} * @return {*}
*/ */
export function formatFormDataValue (value, key = null) { export function formatFormDataValue(value, key = null) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return Promise.all( return Promise.all(value.map((value_) => formatFormDataValue(value_))).then(
value.map(value_ => formatFormDataValue(value_)) (resolvedValues) => ({ [key]: resolvedValues }),
).then(resolvedValues => ({ [key]: resolvedValues })) )
} }
let result = value let result = value
@ -454,10 +495,10 @@ export function formatFormDataValue (value, key = null) {
// File has not changed (will not be sent) // File has not changed (will not be sent)
else if (value.current || value.file === null) result = null else if (value.current || value.file === null) result = null
else { else {
return getFileContent(value.file, { base64: true }).then(content => { return getFileContent(value.file, { base64: true }).then((content) => {
return { return {
[key]: content.replace(/data:[^;]*;base64,/, ''), [key]: content.replace(/data:[^;]*;base64,/, ''),
[key + '[name]']: value.file.name [key + '[name]']: value.file.name,
} }
}) })
} }
@ -469,7 +510,6 @@ export function formatFormDataValue (value, key = null) {
return Promise.resolve(key ? { [key]: result } : result) return Promise.resolve(key ? { [key]: result } : result)
} }
/** /**
* Convinient helper to properly parse a front-end form to its API equivalent. * Convinient helper to properly parse a front-end form to its API equivalent.
* This parse each values asynchronously, allow to inject keys into the final form and * This parse each values asynchronously, allow to inject keys into the final form and
@ -478,17 +518,16 @@ export function formatFormDataValue (value, key = null) {
* @param {Object} formData * @param {Object} formData
* @return {Object} * @return {Object}
*/ */
function formatFormDataValues (formData) { function formatFormDataValues(formData) {
const promisedValues = Object.entries(formData).map(([key, value]) => { const promisedValues = Object.entries(formData).map(([key, value]) => {
return formatFormDataValue(value, key) return formatFormDataValue(value, key)
}) })
return Promise.all(promisedValues).then(resolvedValues => { return Promise.all(promisedValues).then((resolvedValues) => {
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {}) return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {})
}) })
} }
/** /**
* Format a form produced by a vue view to be sent to the server. * Format a form produced by a vue view to be sent to the server.
* *
@ -499,13 +538,18 @@ function formatFormDataValues (formData) {
* @param {Boolean} [extraParams.removeEmpty=true] - Removes "empty" values from the object. * @param {Boolean} [extraParams.removeEmpty=true] - Removes "empty" values from the object.
* @return {Object} the parsed data to be sent to the server, with extracted values if specified. * @return {Object} the parsed data to be sent to the server, with extracted values if specified.
*/ */
export async function formatFormData ( export async function formatFormData(
formData, formData,
{ extract = null, flatten = false, removeEmpty = true, removeNull = false } = {} {
extract = null,
flatten = false,
removeEmpty = true,
removeNull = false,
} = {},
) { ) {
const output = { const output = {
data: {}, data: {},
extracted: {} extracted: {},
} }
const values = await formatFormDataValues(formData) const values = await formatFormDataValues(formData)

View file

@ -11,7 +11,7 @@ const loadedLanguages = []
* *
* @return {string[]} * @return {string[]}
*/ */
function getDefaultLocales () { function getDefaultLocales() {
const locale = store.getters.locale const locale = store.getters.locale
const fallbackLocale = store.getters.fallbackLocale const fallbackLocale = store.getters.fallbackLocale
if (locale && fallbackLocale) return [locale, fallbackLocale] if (locale && fallbackLocale) return [locale, fallbackLocale]
@ -34,7 +34,7 @@ function getDefaultLocales () {
return defaultLocales return defaultLocales
} }
function updateDocumentLocale (locale) { function updateDocumentLocale(locale) {
document.documentElement.lang = locale document.documentElement.lang = locale
// FIXME can't currently change document direction easily since bootstrap still doesn't handle rtl. // FIXME can't currently change document direction easily since bootstrap still doesn't handle rtl.
// document.dir = locale === 'ar' ? 'rtl' : 'ltr' // document.dir = locale === 'ar' ? 'rtl' : 'ltr'
@ -45,11 +45,11 @@ function updateDocumentLocale (locale) {
* *
* @return {Promise<string>} Promise that resolve the given locale string * @return {Promise<string>} Promise that resolve the given locale string
*/ */
function loadLocaleMessages (locale) { function loadLocaleMessages(locale) {
if (loadedLanguages.includes(locale)) { if (loadedLanguages.includes(locale)) {
return Promise.resolve(locale) return Promise.resolve(locale)
} }
return import(`@/i18n/locales/${locale}.json`).then(messages => { return import(`@/i18n/locales/${locale}.json`).then((messages) => {
i18n.setLocaleMessage(locale, messages.default) i18n.setLocaleMessage(locale, messages.default)
loadedLanguages.push(locale) loadedLanguages.push(locale)
return locale return locale
@ -59,17 +59,19 @@ function loadLocaleMessages (locale) {
/** /**
* Loads a date-fns locale object * Loads a date-fns locale object
*/ */
async function loadDateFnsLocale (locale) { async function loadDateFnsLocale(locale) {
const dateFnsLocaleName = supportedLocales[locale].dateFnsLocale || locale const dateFnsLocaleName = supportedLocales[locale].dateFnsLocale || locale
dateFnsLocale = (await import( dateFnsLocale = (
`../../node_modules/date-fns/esm/locale/${dateFnsLocaleName}/index.js` await import(
)).default `../../node_modules/date-fns/esm/locale/${dateFnsLocaleName}/index.js`
)
).default
} }
/** /**
* Initialize all locales * Initialize all locales
*/ */
function initDefaultLocales () { function initDefaultLocales() {
// Get defined locales from `localStorage` or `navigator` // Get defined locales from `localStorage` or `navigator`
const [locale, fallbackLocale] = getDefaultLocales() const [locale, fallbackLocale] = getDefaultLocales()
@ -83,5 +85,5 @@ export {
updateDocumentLocale, updateDocumentLocale,
loadLocaleMessages, loadLocaleMessages,
loadDateFnsLocale, loadDateFnsLocale,
dateFnsLocale dateFnsLocale,
} }

View file

@ -6,135 +6,135 @@
export default { export default {
ar: { ar: {
name: 'عربي' name: 'عربي',
}, },
bn_BD: { bn_BD: {
name: 'বাংলা', name: 'বাংলা',
dateFnsLocale: 'bn' dateFnsLocale: 'bn',
}, },
br: { br: {
name: 'Brezhoneg', name: 'Brezhoneg',
dateFnsLocale: 'fr' dateFnsLocale: 'fr',
}, },
ca: { ca: {
name: 'Català' name: 'Català',
}, },
ckb: { ckb: {
name: 'کوردی', name: 'کوردی',
dateFnsLocale: 'fa-IR' dateFnsLocale: 'fa-IR',
// FIXME fallback to Farsi (`fa-IR`) is arbitrary, some would probably prefer Arabic (`ar`)... // FIXME fallback to Farsi (`fa-IR`) is arbitrary, some would probably prefer Arabic (`ar`)...
}, },
cs: { cs: {
name: 'Čeština' name: 'Čeština',
}, },
da: { da: {
name: 'Dansk' name: 'Dansk',
}, },
de: { de: {
name: 'Deutsch' name: 'Deutsch',
}, },
el: { el: {
name: 'Eλληνικά' name: 'Eλληνικά',
}, },
en: { en: {
name: 'English', name: 'English',
dateFnsLocale: 'en-GB' dateFnsLocale: 'en-GB',
}, },
eo: { eo: {
name: 'Esperanto' name: 'Esperanto',
}, },
es: { es: {
name: 'Español' name: 'Español',
}, },
eu: { eu: {
name: 'Euskara' name: 'Euskara',
}, },
fa: { fa: {
name: 'فارسی', name: 'فارسی',
dateFnsLocale: 'fa-IR' dateFnsLocale: 'fa-IR',
}, },
fi: { fi: {
name: 'Suomi' name: 'Suomi',
}, },
fr: { fr: {
name: 'Français' name: 'Français',
}, },
gl: { gl: {
name: 'Galego' name: 'Galego',
}, },
he: { he: {
name: 'עברית' name: 'עברית',
}, },
hi: { hi: {
name: 'हिन्दी' name: 'हिन्दी',
}, },
hu: { hu: {
name: 'Magyar' name: 'Magyar',
}, },
id: { id: {
name: 'Bahasa Indonesia' name: 'Bahasa Indonesia',
}, },
it: { it: {
name: 'Italiano' name: 'Italiano',
}, },
kab: { kab: {
name: 'Taqbaylit', name: 'Taqbaylit',
dateFnsLocale: 'ar-DZ' dateFnsLocale: 'ar-DZ',
}, },
lt: { lt: {
name: 'Lietuvių' name: 'Lietuvių',
}, },
mk: { mk: {
name: 'македонски' name: 'македонски',
}, },
nb_NO: { nb_NO: {
name: 'Norsk bokmål', name: 'Norsk bokmål',
dateFnsLocale: 'nb' dateFnsLocale: 'nb',
}, },
ne: { ne: {
name: 'नेपाली', name: 'नेपाली',
dateFnsLocale: 'en-GB' dateFnsLocale: 'en-GB',
}, },
nl: { nl: {
name: 'Nederlands' name: 'Nederlands',
}, },
oc: { oc: {
name: 'Occitan', name: 'Occitan',
dateFnsLocale: 'ca' dateFnsLocale: 'ca',
}, },
pl: { pl: {
name: 'Polski' name: 'Polski',
}, },
pt: { pt: {
name: 'Português' name: 'Português',
}, },
pt_BR: { pt_BR: {
name: 'Português brasileiro', name: 'Português brasileiro',
dateFnsLocale: 'pt-BR' dateFnsLocale: 'pt-BR',
}, },
ru: { ru: {
name: 'Русский' name: 'Русский',
}, },
sk: { sk: {
name: 'Slovak' name: 'Slovak',
}, },
sl: { sl: {
name: 'Slovenščina' name: 'Slovenščina',
}, },
sv: { sv: {
name: 'Svenska' name: 'Svenska',
}, },
te: { te: {
name: 'Telugu' name: 'Telugu',
}, },
tr: { tr: {
name: 'Türkçe' name: 'Türkçe',
}, },
uk: { uk: {
name: 'Українська' name: 'Українська',
}, },
zh_Hans: { zh_Hans: {
name: '简化字', name: '简化字',
dateFnsLocale: 'zh-CN' dateFnsLocale: 'zh-CN',
} },
} }

View file

@ -10,20 +10,19 @@ import i18n from './i18n'
import { registerGlobalErrorHandlers } from './api' import { registerGlobalErrorHandlers } from './api'
import { initDefaultLocales } from './i18n/helpers' import { initDefaultLocales } from './i18n/helpers'
Vue.config.productionTip = false Vue.config.productionTip = false
// Styles are imported in `src/App.vue` <style> // Styles are imported in `src/App.vue` <style>
Vue.use(BootstrapVue, { Vue.use(BootstrapVue, {
BSkeleton: { animation: 'none' }, BSkeleton: { animation: 'none' },
BAlert: { show: true }, BAlert: { show: true },
BBadge: { pill: true } BBadge: { pill: true },
}) })
Vue.use(VueShowdown, { Vue.use(VueShowdown, {
options: { options: {
emoji: true emoji: true,
} },
}) })
// Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles // Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles
@ -34,14 +33,18 @@ Vue.prototype.$askConfirmation = function (message, props) {
cancelTitle: this.$i18n.t('cancel'), cancelTitle: this.$i18n.t('cancel'),
bodyBgVariant: 'warning', bodyBgVariant: 'warning',
centered: true, centered: true,
bodyClass: ['font-weight-bold', 'rounded-top', store.state.theme ? 'text-white' : 'text-black'], bodyClass: [
...props 'font-weight-bold',
'rounded-top',
store.state.theme ? 'text-white' : 'text-black',
],
...props,
}) })
} }
Vue.prototype.$askMdConfirmation = function (markdown, props, ok = false) { Vue.prototype.$askMdConfirmation = function (markdown, props, ok = false) {
const content = this.$createElement('vue-showdown', { const content = this.$createElement('vue-showdown', {
props: { markdown, flavor: 'github', options: { headerLevelStart: 4 } } props: { markdown, flavor: 'github', options: { headerLevelStart: 4 } },
}) })
return this.$bvModal['msgBox' + (ok ? 'Ok' : 'Confirm')](content, { return this.$bvModal['msgBox' + (ok ? 'Ok' : 'Confirm')](content, {
okTitle: this.$i18n.t('yes'), okTitle: this.$i18n.t('yes'),
@ -49,15 +52,15 @@ Vue.prototype.$askMdConfirmation = function (markdown, props, ok = false) {
headerBgVariant: 'warning', headerBgVariant: 'warning',
headerClass: store.state.theme ? 'text-white' : 'text-black', headerClass: store.state.theme ? 'text-white' : 'text-black',
centered: true, centered: true,
...props ...props,
}) })
} }
// Register global components // Register global components
const globalComponentsModules = import.meta.glob([ const globalComponentsModules = import.meta.glob(
'@/components/globals/*.vue', ['@/components/globals/*.vue', '@/components/globals/*/*.vue'],
'@/components/globals/*/*.vue' { eager: true },
], { eager: true }) )
Object.values(globalComponentsModules).forEach((module) => { Object.values(globalComponentsModules).forEach((module) => {
const component = module.default const component = module.default
Vue.component(component.name, component) Vue.component(component.name, component)
@ -71,7 +74,7 @@ initDefaultLocales().then(() => {
store, store,
router, router,
i18n, i18n,
render: h => h(App) render: (h) => h(App),
}) })
app.$mount('#app') app.$mount('#app')

View file

@ -10,7 +10,7 @@ const router = new VueRouter({
base: import.meta.env.BASE_URL, base: import.meta.env.BASE_URL,
routes, routes,
scrollBehavior (to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
// Mimics the native scroll behavior of the browser. // Mimics the native scroll behavior of the browser.
// This allows the user to find his way back to the scroll level of the previous/next route. // This allows the user to find his way back to the scroll level of the previous/next route.
@ -18,13 +18,13 @@ const router = new VueRouter({
// scroll state because the component probably hasn't updated the window height yet. // scroll state because the component probably hasn't updated the window height yet.
// Note: this will only work with routes that use stored data or that has static content // Note: this will only work with routes that use stored data or that has static content
if (store.getters.transitions && savedPosition) { if (store.getters.transitions && savedPosition) {
return new Promise(resolve => { return new Promise((resolve) => {
setTimeout(() => resolve(savedPosition), 0) setTimeout(() => resolve(savedPosition), 0)
}) })
} else { } else {
return savedPosition || { x: 0, y: 0 } return savedPosition || { x: 0, y: 0 }
} }
} },
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {

View file

@ -18,8 +18,8 @@ const routes = [
path: '/', path: '/',
component: HomeView, component: HomeView,
meta: { meta: {
args: { trad: 'home' } args: { trad: 'home' },
} },
}, },
{ {
@ -28,8 +28,8 @@ const routes = [
component: LoginView, component: LoginView,
meta: { meta: {
noAuth: true, noAuth: true,
args: { trad: 'login' } args: { trad: 'login' },
} },
}, },
/* /*
@ -41,8 +41,8 @@ const routes = [
component: () => import('@/views/PostInstall.vue'), component: () => import('@/views/PostInstall.vue'),
meta: { meta: {
noAuth: true, noAuth: true,
args: { trad: 'postinstall.title' } args: { trad: 'postinstall.title' },
} },
}, },
/* /*
@ -54,8 +54,8 @@ const routes = [
component: () => import('@/views/user/UserList.vue'), component: () => import('@/views/user/UserList.vue'),
meta: { meta: {
args: { trad: 'users' }, args: { trad: 'users' },
breadcrumb: ['user-list'] breadcrumb: ['user-list'],
} },
}, },
{ {
name: 'user-create', name: 'user-create',
@ -63,8 +63,8 @@ const routes = [
component: () => import('@/views/user/UserCreate.vue'), component: () => import('@/views/user/UserCreate.vue'),
meta: { meta: {
args: { trad: 'users_new' }, args: { trad: 'users_new' },
breadcrumb: ['user-list', 'user-create'] breadcrumb: ['user-list', 'user-create'],
} },
}, },
{ {
name: 'user-import', name: 'user-import',
@ -73,8 +73,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { trad: 'users_import' }, args: { trad: 'users_import' },
breadcrumb: ['user-list', 'user-import'] breadcrumb: ['user-list', 'user-import'],
} },
}, },
{ {
name: 'user-info', name: 'user-info',
@ -83,8 +83,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { param: 'name' }, args: { param: 'name' },
breadcrumb: ['user-list', 'user-info'] breadcrumb: ['user-list', 'user-info'],
} },
}, },
{ {
name: 'user-edit', name: 'user-edit',
@ -93,8 +93,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { param: 'name', trad: 'user_username_edit' }, args: { param: 'name', trad: 'user_username_edit' },
breadcrumb: ['user-list', 'user-info', 'user-edit'] breadcrumb: ['user-list', 'user-info', 'user-edit'],
} },
}, },
/* /*
@ -106,8 +106,8 @@ const routes = [
component: () => import('@/views/group/GroupList.vue'), component: () => import('@/views/group/GroupList.vue'),
meta: { meta: {
args: { trad: 'groups_and_permissions' }, args: { trad: 'groups_and_permissions' },
breadcrumb: ['user-list', 'group-list'] breadcrumb: ['user-list', 'group-list'],
} },
}, },
{ {
name: 'group-create', name: 'group-create',
@ -115,8 +115,8 @@ const routes = [
component: () => import('@/views/group/GroupCreate.vue'), component: () => import('@/views/group/GroupCreate.vue'),
meta: { meta: {
args: { trad: 'group_new' }, args: { trad: 'group_new' },
breadcrumb: ['user-list', 'group-list', 'group-create'] breadcrumb: ['user-list', 'group-list', 'group-create'],
} },
}, },
/* /*
@ -128,8 +128,8 @@ const routes = [
component: () => import('@/views/domain/DomainList.vue'), component: () => import('@/views/domain/DomainList.vue'),
meta: { meta: {
args: { trad: 'domains' }, args: { trad: 'domains' },
breadcrumb: ['domain-list'] breadcrumb: ['domain-list'],
} },
}, },
{ {
name: 'domain-add', name: 'domain-add',
@ -137,8 +137,8 @@ const routes = [
component: () => import('@/views/domain/DomainAdd.vue'), component: () => import('@/views/domain/DomainAdd.vue'),
meta: { meta: {
args: { trad: 'domain_add' }, args: { trad: 'domain_add' },
breadcrumb: ['domain-list', 'domain-add'] breadcrumb: ['domain-list', 'domain-add'],
} },
}, },
{ {
path: '/domains/:name', path: '/domains/:name',
@ -153,10 +153,10 @@ const routes = [
meta: { meta: {
routerParams: ['name'], // Override router key params to avoid view recreation at tab change. routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
args: { param: 'name' }, args: { param: 'name' },
breadcrumb: ['domain-list', 'domain-info'] breadcrumb: ['domain-list', 'domain-info'],
} },
} },
] ],
}, },
/* /*
@ -168,18 +168,18 @@ const routes = [
component: () => import('@/views/app/AppList.vue'), component: () => import('@/views/app/AppList.vue'),
meta: { meta: {
args: { trad: 'applications' }, args: { trad: 'applications' },
breadcrumb: ['app-list'] breadcrumb: ['app-list'],
} },
}, },
{ {
name: 'app-catalog', name: 'app-catalog',
path: '/apps/catalog', path: '/apps/catalog',
component: () => import('@/views/app/AppCatalog.vue'), component: () => import('@/views/app/AppCatalog.vue'),
props: route => route.query, props: (route) => route.query,
meta: { meta: {
args: { trad: 'catalog' }, args: { trad: 'catalog' },
breadcrumb: ['app-list', 'app-catalog'] breadcrumb: ['app-list', 'app-catalog'],
} },
}, },
{ {
name: 'app-install', name: 'app-install',
@ -188,8 +188,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { trad: 'install_name', param: 'id' }, args: { trad: 'install_name', param: 'id' },
breadcrumb: ['app-list', 'app-catalog', 'app-install'] breadcrumb: ['app-list', 'app-catalog', 'app-install'],
} },
}, },
{ {
name: 'app-install-custom', name: 'app-install-custom',
@ -198,8 +198,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { trad: 'install_name', param: 'id' }, args: { trad: 'install_name', param: 'id' },
breadcrumb: ['app-list', 'app-catalog', 'app-install-custom'] breadcrumb: ['app-list', 'app-catalog', 'app-install-custom'],
} },
}, },
{ {
path: '/apps/:id', path: '/apps/:id',
@ -214,10 +214,10 @@ const routes = [
meta: { meta: {
routerParams: ['id'], // Override router key params to avoid view recreation at tab change. routerParams: ['id'], // Override router key params to avoid view recreation at tab change.
args: { param: 'id' }, args: { param: 'id' },
breadcrumb: ['app-list', 'app-info'] breadcrumb: ['app-list', 'app-info'],
} },
} },
] ],
}, },
/* /*
@ -229,8 +229,8 @@ const routes = [
component: () => import('@/views/update/SystemUpdate.vue'), component: () => import('@/views/update/SystemUpdate.vue'),
meta: { meta: {
args: { trad: 'system_update' }, args: { trad: 'system_update' },
breadcrumb: ['update'] breadcrumb: ['update'],
} },
}, },
/* /*
@ -242,8 +242,8 @@ const routes = [
component: () => import('@/views/service/ServiceList.vue'), component: () => import('@/views/service/ServiceList.vue'),
meta: { meta: {
args: { trad: 'services' }, args: { trad: 'services' },
breadcrumb: ['tool-list', 'service-list'] breadcrumb: ['tool-list', 'service-list'],
} },
}, },
{ {
name: 'service-info', name: 'service-info',
@ -252,8 +252,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { param: 'name' }, args: { param: 'name' },
breadcrumb: ['tool-list', 'service-list', 'service-info'] breadcrumb: ['tool-list', 'service-list', 'service-info'],
} },
}, },
/* /*
@ -265,8 +265,8 @@ const routes = [
component: ToolList, component: ToolList,
meta: { meta: {
args: { trad: 'tools' }, args: { trad: 'tools' },
breadcrumb: ['tool-list'] breadcrumb: ['tool-list'],
} },
}, },
{ {
name: 'tool-logs', name: 'tool-logs',
@ -274,8 +274,8 @@ const routes = [
component: () => import('@/views/tool/ToolLogs.vue'), component: () => import('@/views/tool/ToolLogs.vue'),
meta: { meta: {
args: { trad: 'logs' }, args: { trad: 'logs' },
breadcrumb: ['tool-list', 'tool-logs'] breadcrumb: ['tool-list', 'tool-logs'],
} },
}, },
{ {
name: 'tool-log', name: 'tool-log',
@ -284,8 +284,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { param: 'name' }, args: { param: 'name' },
breadcrumb: ['tool-list', 'tool-logs', 'tool-log'] breadcrumb: ['tool-list', 'tool-logs', 'tool-log'],
} },
}, },
{ {
name: 'tool-migrations', name: 'tool-migrations',
@ -293,8 +293,8 @@ const routes = [
component: () => import('@/views/tool/ToolMigrations.vue'), component: () => import('@/views/tool/ToolMigrations.vue'),
meta: { meta: {
args: { trad: 'migrations' }, args: { trad: 'migrations' },
breadcrumb: ['tool-list', 'tool-migrations'] breadcrumb: ['tool-list', 'tool-migrations'],
} },
}, },
{ {
name: 'tool-firewall', name: 'tool-firewall',
@ -302,8 +302,8 @@ const routes = [
component: () => import('@/views/tool/ToolFirewall.vue'), component: () => import('@/views/tool/ToolFirewall.vue'),
meta: { meta: {
args: { trad: 'firewall' }, args: { trad: 'firewall' },
breadcrumb: ['tool-list', 'tool-firewall'] breadcrumb: ['tool-list', 'tool-firewall'],
} },
}, },
{ {
name: 'tool-webadmin', name: 'tool-webadmin',
@ -311,8 +311,8 @@ const routes = [
component: () => import('@/views/tool/ToolWebadmin.vue'), component: () => import('@/views/tool/ToolWebadmin.vue'),
meta: { meta: {
args: { trad: 'tools_webadmin_settings' }, args: { trad: 'tools_webadmin_settings' },
breadcrumb: ['tool-list', 'tool-webadmin'] breadcrumb: ['tool-list', 'tool-webadmin'],
} },
}, },
{ {
path: '/tools/settings', path: '/tools/settings',
@ -326,10 +326,10 @@ const routes = [
meta: { meta: {
routerParams: [], routerParams: [],
args: { trad: 'tools_yunohost_settings' }, args: { trad: 'tools_yunohost_settings' },
breadcrumb: ['tool-list', 'tool-settings'] breadcrumb: ['tool-list', 'tool-settings'],
} },
} },
] ],
}, },
{ {
name: 'tool-power', name: 'tool-power',
@ -337,8 +337,8 @@ const routes = [
component: () => import('@/views/tool/ToolPower.vue'), component: () => import('@/views/tool/ToolPower.vue'),
meta: { meta: {
args: { trad: 'tools_shutdown_reboot' }, args: { trad: 'tools_shutdown_reboot' },
breadcrumb: ['tool-list', 'tool-power'] breadcrumb: ['tool-list', 'tool-power'],
} },
}, },
/* /*
@ -350,8 +350,8 @@ const routes = [
component: () => import('@/views/diagnosis/DiagnosisView.vue'), component: () => import('@/views/diagnosis/DiagnosisView.vue'),
meta: { meta: {
args: { trad: 'diagnosis' }, args: { trad: 'diagnosis' },
breadcrumb: ['diagnosis'] breadcrumb: ['diagnosis'],
} },
}, },
/* /*
@ -363,8 +363,8 @@ const routes = [
component: () => import('@/views/backup/BackupView.vue'), component: () => import('@/views/backup/BackupView.vue'),
meta: { meta: {
args: { trad: 'backup' }, args: { trad: 'backup' },
breadcrumb: ['backup'] breadcrumb: ['backup'],
} },
}, },
{ {
name: 'backup-list', name: 'backup-list',
@ -373,8 +373,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { param: 'id' }, args: { param: 'id' },
breadcrumb: ['backup', 'backup-list'] breadcrumb: ['backup', 'backup-list'],
} },
}, },
{ {
name: 'backup-info', name: 'backup-info',
@ -383,8 +383,8 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { param: 'name' }, args: { param: 'name' },
breadcrumb: ['backup', 'backup-list', 'backup-info'] breadcrumb: ['backup', 'backup-list', 'backup-info'],
} },
}, },
{ {
name: 'backup-create', name: 'backup-create',
@ -393,9 +393,9 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { trad: 'backup_create' }, args: { trad: 'backup_create' },
breadcrumb: ['backup', 'backup-list', 'backup-create'] breadcrumb: ['backup', 'backup-list', 'backup-create'],
} },
} },
] ]
export default routes export default routes

View file

@ -34,11 +34,13 @@
0: h, 0: h,
1: s, 1: s,
2: l, 2: l,
3: a 3: a,
); );
// find end of part // find end of part
$end: str-index($color, ','); $end: str-index($color, ',');
@while ($end and not str-is-between(str-slice($color, 0, $end - 1), '(', ')')) { @while (
$end and not str-is-between(str-slice($color, 0, $end - 1), '(', ')')
) {
$newEnd: str-index(str-slice($color, $end + 1), ','); $newEnd: str-index(str-slice($color, $end + 1), ',');
@if (not $newEnd) { @if (not $newEnd) {
$newEnd: 0; $newEnd: 0;
@ -49,7 +51,7 @@
$part: str-slice($color, 0, $end - 1); $part: str-slice($color, 0, $end - 1);
$value: map-merge( $value: map-merge(
( (
map-get($indices, $index): $part map-get($indices, $index): $part,
), ),
recursive-color(str-slice($color, $end + 1), $index + 1) recursive-color(str-slice($color, $end + 1), $index + 1)
); );
@ -220,15 +222,14 @@
} }
} }
// Taken from https://gist.github.com/johanlef/518a511b2b2f6b96c4f429b3af2f169a?permalink_comment_id=4053335#gistcomment-4053335 // Taken from https://gist.github.com/johanlef/518a511b2b2f6b96c4f429b3af2f169a?permalink_comment_id=4053335#gistcomment-4053335
@function theme-color-level($color-name: "primary", $level: 0) { @function theme-color-level($color-name: 'primary', $level: 0) {
$color: theme-color($color-name); $color: theme-color($color-name);
@if ($level == 0) { @if ($level == 0) {
@return $color; @return $color;
} }
$amount: math.div($theme-color-interval * abs($level) , 100%); $amount: math.div($theme-color-interval * abs($level), 100%);
$c: to-hsl($color); $c: to-hsl($color);
$h: map-get($c, h); $h: map-get($c, h);
$s: map-get($c, s); $s: map-get($c, s);

View file

@ -1,4 +1,3 @@
// //
// //
// /!\ DO NOT IMPORT OR DEFINE ACTUAL RULES INTO THIS FILE /!\ // /!\ DO NOT IMPORT OR DEFINE ACTUAL RULES INTO THIS FILE /!\
@ -12,7 +11,6 @@
// But if some rules are defined here, they will be copied into the final build as many // But if some rules are defined here, they will be copied into the final build as many
// times as there are components // times as there are components
// //
// //
// //
@ -27,10 +25,10 @@
// For exemple, turning rounding of elements off, the bases colors, etc. // For exemple, turning rounding of elements off, the bases colors, etc.
// $enable-rounded: false; // $enable-rounded: false;
$font-size-base: .9rem; $font-size-base: 0.9rem;
$font-weight-bold: 500; $font-weight-bold: 500;
$white: var(--white); $white: var(--white);
$gray-100: var(--gray-100); $gray-100: var(--gray-100);
$gray-200: var(--gray-200); $gray-200: var(--gray-200);
$gray-300: var(--gray-300); $gray-300: var(--gray-300);
@ -40,18 +38,18 @@ $gray-600: var(--gray-600);
$gray-700: var(--gray-700); $gray-700: var(--gray-700);
$gray-800: var(--gray-800); $gray-800: var(--gray-800);
$gray-900: var(--gray-900); $gray-900: var(--gray-900);
$black: var(--black); $black: var(--black);
$blue: var(--blue); $blue: var(--blue);
$indigo: var(--indigo); $indigo: var(--indigo);
$purple: var(--purple); $purple: var(--purple);
$pink: var(--pink); $pink: var(--pink);
$red: var(--red); $red: var(--red);
$orange: var(--orange); $orange: var(--orange);
$yellow: var(--yellow); $yellow: var(--yellow);
$green: var(--green); $green: var(--green);
$teal: var(--teal); $teal: var(--teal);
$cyan: var(--cyan); $cyan: var(--cyan);
$theme-colors: ( $theme-colors: (
'best': $purple, 'best': $purple,
@ -59,12 +57,12 @@ $theme-colors: (
$yiq-contrasted-threshold: var(--yiq-contrasted-threshold); $yiq-contrasted-threshold: var(--yiq-contrasted-threshold);
$alert-bg-level: -10; $alert-bg-level: -10;
$alert-border-level: -9; $alert-border-level: -9;
$alert-color-level: 5; $alert-color-level: 5;
$list-group-item-bg-level: -11; $list-group-item-bg-level: -11;
$list-group-item-color-level: 6; $list-group-item-color-level: 6;
$code-color: $black; $code-color: $black;
@ -77,8 +75,23 @@ $display4-weight: 200;
$lead-font-weight: 200; $lead-font-weight: 200;
// Set fonts // Set fonts
$font-family-sans-serif: 'FiraGO', 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' !default; $font-family-sans-serif:
$font-family-monospace: 'Fira Code', SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !default; 'FiraGO',
'Fira Sans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji' !default;
$font-family-monospace: 'Fira Code', SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace !default;
$h2-font-size: $font-size-base * 1.5; $h2-font-size: $font-size-base * 1.5;
$h3-font-size: $font-size-base * 1.4; $h3-font-size: $font-size-base * 1.4;
@ -87,7 +100,7 @@ $h5-font-size: $font-size-base * 1.1;
$alert-padding-x: 1rem; $alert-padding-x: 1rem;
$card-spacer-y: .6rem; $card-spacer-y: 0.6rem;
$card-spacer-x: 1rem; $card-spacer-x: 1rem;
$list-group-item-padding-x: 1rem; $list-group-item-padding-x: 1rem;
@ -105,7 +118,9 @@ $b-toast-background-opacity: 100%;
} }
$custom-checkbox-indicator-icon-checked: get-checkbox-icon-checked('%23fff'); $custom-checkbox-indicator-icon-checked: get-checkbox-icon-checked('%23fff');
$custom-checkbox-indicator-icon-indeterminate: get-checkbox-icon-indeterminate('%23fff'); $custom-checkbox-indicator-icon-indeterminate: get-checkbox-icon-indeterminate(
'%23fff'
);
// Import default variables after the above setup to compute all other variables. // Import default variables after the above setup to compute all other variables.
@import '~bootstrap/scss/functions.scss'; @import '~bootstrap/scss/functions.scss';
@ -114,7 +129,6 @@ $custom-checkbox-indicator-icon-indeterminate: get-checkbox-icon-indeterminate('
@import '~bootstrap/scss/mixins.scss'; @import '~bootstrap/scss/mixins.scss';
@import '~bootstrap-vue/src/variables'; @import '~bootstrap-vue/src/variables';
$hr-border-color: $gray-200; $hr-border-color: $gray-200;
$list-group-action-color: $gray-800; $list-group-action-color: $gray-800;
@ -133,7 +147,6 @@ $fa-font-size-base: $font-size-base;
@import '~fork-awesome/scss/variables'; @import '~fork-awesome/scss/variables';
// //
// //
// //
@ -142,6 +155,6 @@ $fa-font-size-base: $font-size-base;
$thin-border: $hr-border-width solid $hr-border-color; $thin-border: $hr-border-width solid $hr-border-color;
$btn-padding-y-xs: .25rem; $btn-padding-y-xs: 0.25rem;
$btn-padding-x-xs: .35rem; $btn-padding-x-xs: 0.35rem;
$btn-line-height-xs: 1.5; $btn-line-height-xs: 1.5;

View file

@ -108,7 +108,8 @@
src: src:
local('Fira Code Regular'), local('Fira Code Regular'),
// url('~@fontsource/fira-code/files/fira-code-all-400-normal.woff2') format('woff2'), // url('~@fontsource/fira-code/files/fira-code-all-400-normal.woff2') format('woff2'),
url('~@fontsource/fira-code/files/fira-code-all-400-normal.woff') format('woff'); url('~@fontsource/fira-code/files/fira-code-all-400-normal.woff')
format('woff');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }

View file

@ -5,42 +5,41 @@
// Variables overrides are defined before actual SCSS imports // Variables overrides are defined before actual SCSS imports
@import 'variables'; @import 'variables';
// Dependencies SCSS imports // Dependencies SCSS imports
// `~` allow to import a node_modules folder (resolved by Webpack) // `~` allow to import a node_modules folder (resolved by Webpack)
// @import "~bootstrap/scss/root"; // @import "~bootstrap/scss/root";
@import "~bootstrap/scss/reboot"; @import '~bootstrap/scss/reboot';
@import "~bootstrap/scss/type"; @import '~bootstrap/scss/type';
@import "~bootstrap/scss/images"; @import '~bootstrap/scss/images';
@import "~bootstrap/scss/code"; @import '~bootstrap/scss/code';
@import "~bootstrap/scss/grid"; @import '~bootstrap/scss/grid';
@import "~bootstrap/scss/tables"; @import '~bootstrap/scss/tables';
@import "~bootstrap/scss/forms"; @import '~bootstrap/scss/forms';
@import "~bootstrap/scss/buttons"; @import '~bootstrap/scss/buttons';
@import "~bootstrap/scss/transitions"; @import '~bootstrap/scss/transitions';
@import "~bootstrap/scss/dropdown"; @import '~bootstrap/scss/dropdown';
@import "~bootstrap/scss/button-group"; @import '~bootstrap/scss/button-group';
@import "~bootstrap/scss/input-group"; @import '~bootstrap/scss/input-group';
@import "~bootstrap/scss/custom-forms"; @import '~bootstrap/scss/custom-forms';
@import "~bootstrap/scss/nav"; @import '~bootstrap/scss/nav';
@import "~bootstrap/scss/navbar"; @import '~bootstrap/scss/navbar';
@import "~bootstrap/scss/card"; @import '~bootstrap/scss/card';
@import "~bootstrap/scss/breadcrumb"; @import '~bootstrap/scss/breadcrumb';
// @import "~bootstrap/scss/pagination"; // @import "~bootstrap/scss/pagination";
@import "~bootstrap/scss/badge"; @import '~bootstrap/scss/badge';
// @import "~bootstrap/scss/jumbotron"; // @import "~bootstrap/scss/jumbotron";
@import "~bootstrap/scss/alert"; @import '~bootstrap/scss/alert';
@import "~bootstrap/scss/progress"; @import '~bootstrap/scss/progress';
// @import "~bootstrap/scss/media"; // @import "~bootstrap/scss/media";
@import "~bootstrap/scss/list-group"; @import '~bootstrap/scss/list-group';
@import "~bootstrap/scss/close"; @import '~bootstrap/scss/close';
// @import "~bootstrap/scss/toasts"; // @import "~bootstrap/scss/toasts";
@import "~bootstrap/scss/modal"; @import '~bootstrap/scss/modal';
@import "~bootstrap/scss/tooltip"; @import '~bootstrap/scss/tooltip';
@import "~bootstrap/scss/popover"; @import '~bootstrap/scss/popover';
// @import "~bootstrap/scss/carousel"; // @import "~bootstrap/scss/carousel";
@import "~bootstrap/scss/spinners"; @import '~bootstrap/scss/spinners';
@import "~bootstrap/scss/utilities"; @import '~bootstrap/scss/utilities';
// @import "~bootstrap/scss/print"; // @import "~bootstrap/scss/print";
@import '~bootstrap-vue/src/index.scss'; @import '~bootstrap-vue/src/index.scss';
@ -87,18 +86,22 @@
// Overwrite list-group-item variants to lighter ones (used in diagnosis for example) // Overwrite list-group-item variants to lighter ones (used in diagnosis for example)
@each $color, $value in $theme-colors { @each $color, $value in $theme-colors {
@include list-group-item-variant($color, theme-color-level($color, $list-group-item-bg-level), theme-color-level($color, $list-group-item-color-level)); @include list-group-item-variant(
$color,
theme-color-level($color, $list-group-item-bg-level),
theme-color-level($color, $list-group-item-color-level)
);
.btn-#{$color} { .btn-#{$color} {
&:focus, &:focus,
&.focus { &.focus {
box-shadow: 0 0 0 $btn-focus-width rgba($value, .3); box-shadow: 0 0 0 $btn-focus-width rgba($value, 0.3);
} }
} }
} }
} }
[dark-theme="true"] { [dark-theme='true'] {
color-scheme: dark; // Ask browser to use dark mode native styling color-scheme: dark; // Ask browser to use dark mode native styling
--yiq-contrasted-threshold: 120; --yiq-contrasted-threshold: 120;
@ -122,10 +125,18 @@
@include hsl-color('gray-100', 256, 0%, 15%); @include hsl-color('gray-100', 256, 0%, 15%);
@each $color, $value in $theme-colors { @each $color, $value in $theme-colors {
@include list-group-item-variant($color, theme-color-level($color, -6), theme-color-level($color, 2)); @include list-group-item-variant(
$color,
theme-color-level($color, -6),
theme-color-level($color, 2)
);
.alert-#{$color} { .alert-#{$color} {
@include alert-variant(theme-color-level($color, -6), theme-color-level($color, -5), theme-color-level($color, 2)); @include alert-variant(
theme-color-level($color, -6),
theme-color-level($color, -5),
theme-color-level($color, 2)
);
} }
} }
@ -164,7 +175,6 @@ body {
align-items: center; align-items: center;
} }
// Add breakpoints for w-* // Add breakpoints for w-*
@each $breakpoint in map-keys($grid-breakpoints) { @each $breakpoint in map-keys($grid-breakpoints) {
@each $size, $length in $sizes { @each $size, $length in $sizes {
@ -178,7 +188,13 @@ body {
// Add xs sized btn // Add xs sized btn
.btn-xs { .btn-xs {
@include button-size($btn-padding-y-xs, $btn-padding-x-xs, $btn-font-size-sm, $btn-line-height-xs, $btn-border-radius-sm); @include button-size(
$btn-padding-y-xs,
$btn-padding-x-xs,
$btn-font-size-sm,
$btn-line-height-xs,
$btn-border-radius-sm
);
} }
// Allow state of input group to be displayed under the group // Allow state of input group to be displayed under the group
@ -186,8 +202,9 @@ body {
display: block; display: block;
} }
.tooltip {
.tooltip { top: 0; } top: 0;
}
// Descriptive list (<b-row /> elems with <b-col> inside) // Descriptive list (<b-row /> elems with <b-col> inside)
// FIXME REMOVE when every infos switch to `DescriptionRow` // FIXME REMOVE when every infos switch to `DescriptionRow`
.row-line { .row-line {
@ -199,31 +216,45 @@ body {
} }
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
flex-direction: column; flex-direction: column;
&:not(:last-of-type) { &:not(:last-of-type) {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: $border-width solid $card-border-color; border-bottom: $border-width solid $card-border-color;
} }
} }
} }
.card + .card, .card + .config-panel, .config-panel + .card { .card + .card,
.card + .config-panel,
.config-panel + .card {
margin-top: 2rem; margin-top: 2rem;
} }
.card-deck .card + .card { .card-deck .card + .card {
margin-top: 0; margin-top: 0;
} }
.card-header, .list-group-item { .card-header,
h1, h2, h3, h4, h5, h6 { .list-group-item {
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0; margin: 0;
} }
} }
.card-header, .list-group-item { .card-header,
h1, h2, h3, h4, h5, h6 { .list-group-item {
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
} }
} }
@ -275,14 +306,14 @@ h3.card-title {
justify-content: space-between; justify-content: space-between;
.btn { .btn {
margin-bottom: .5rem; margin-bottom: 0.5rem;
} }
} }
} }
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
.btn ~ .btn { .btn ~ .btn {
margin-left: .5rem; margin-left: 0.5rem;
} }
.btn ~ .dropdown-toggle-split { .btn ~ .dropdown-toggle-split {
margin-left: 0; margin-left: 0;
@ -302,7 +333,7 @@ h3.card-title {
code { code {
background: $gray-300; background: $gray-300;
padding: .15rem .25rem; padding: 0.15rem 0.25rem;
border-radius: $border-radius; border-radius: $border-radius;
} }

View file

@ -4,8 +4,7 @@ import api from '@/api'
import { isEmptyValue } from '@/helpers/commons' import { isEmptyValue } from '@/helpers/commons'
import { stratify } from '@/helpers/data/tree' import { stratify } from '@/helpers/data/tree'
export function getParentDomain(domain, domains, highest = false) {
export function getParentDomain (domain, domains, highest = false) {
const method = highest ? 'lastIndexOf' : 'indexOf' const method = highest ? 'lastIndexOf' : 'indexOf'
let i = domain[method]('.') let i = domain[method]('.')
while (i !== -1) { while (i !== -1) {
@ -17,7 +16,6 @@ export function getParentDomain (domain, domains, highest = false) {
return null return null
} }
export default { export default {
state: () => ({ state: () => ({
main_domain: undefined, main_domain: undefined,
@ -26,36 +24,36 @@ export default {
users: undefined, // basic user data: Object {username: {data}} users: undefined, // basic user data: Object {username: {data}}
users_details: {}, // precise user data: Object {username: {data}} users_details: {}, // precise user data: Object {username: {data}}
groups: undefined, groups: undefined,
permissions: undefined permissions: undefined,
}), }),
mutations: { mutations: {
'SET_DOMAINS' (state, [{ domains, main }]) { SET_DOMAINS(state, [{ domains, main }]) {
state.domains = domains state.domains = domains
state.main_domain = main state.main_domain = main
}, },
'SET_DOMAINS_DETAILS' (state, [name, details]) { SET_DOMAINS_DETAILS(state, [name, details]) {
Vue.set(state.domains_details, name, details) Vue.set(state.domains_details, name, details)
}, },
'UPDATE_DOMAINS_DETAILS' (state, payload) { UPDATE_DOMAINS_DETAILS(state, payload) {
// FIXME use a common function to execute the same code ? // FIXME use a common function to execute the same code ?
this.commit('SET_DOMAINS_DETAILS', payload) this.commit('SET_DOMAINS_DETAILS', payload)
}, },
'DEL_DOMAINS_DETAILS' (state, [name]) { DEL_DOMAINS_DETAILS(state, [name]) {
Vue.delete(state.domains_details, name) Vue.delete(state.domains_details, name)
if (state.domains) { if (state.domains) {
Vue.delete(state.domains, name) Vue.delete(state.domains, name)
} }
}, },
'ADD_DOMAINS' (state, [{ domain }]) { ADD_DOMAINS(state, [{ domain }]) {
state.domains.push(domain) state.domains.push(domain)
}, },
'DEL_DOMAINS' (state, [domain]) { DEL_DOMAINS(state, [domain]) {
state.domains.splice(state.domains.indexOf(domain), 1) state.domains.splice(state.domains.indexOf(domain), 1)
}, },
@ -64,20 +62,20 @@ export default {
// state.main_domain = response.current_main_domain // state.main_domain = response.current_main_domain
// }, // },
'UPDATE_MAIN_DOMAIN' (state, [domain]) { UPDATE_MAIN_DOMAIN(state, [domain]) {
state.main_domain = domain state.main_domain = domain
}, },
'SET_USERS' (state, [users]) { SET_USERS(state, [users]) {
state.users = users || null state.users = users || null
}, },
'ADD_USERS' (state, [user]) { ADD_USERS(state, [user]) {
if (!state.users) state.users = {} if (!state.users) state.users = {}
Vue.set(state.users, user.username, user) Vue.set(state.users, user.username, user)
}, },
'SET_USERS_DETAILS' (state, [username, userData]) { SET_USERS_DETAILS(state, [username, userData]) {
Vue.set(state.users_details, username, userData) Vue.set(state.users_details, username, userData)
if (!state.users) return if (!state.users) return
const user = state.users[username] const user = state.users[username]
@ -88,12 +86,12 @@ export default {
} }
}, },
'UPDATE_USERS_DETAILS' (state, payload) { UPDATE_USERS_DETAILS(state, payload) {
// FIXME use a common function to execute the same code ? // FIXME use a common function to execute the same code ?
this.commit('SET_USERS_DETAILS', payload) this.commit('SET_USERS_DETAILS', payload)
}, },
'DEL_USERS_DETAILS' (state, [username]) { DEL_USERS_DETAILS(state, [username]) {
Vue.delete(state.users_details, username) Vue.delete(state.users_details, username)
if (state.users) { if (state.users) {
Vue.delete(state.users, username) Vue.delete(state.users, username)
@ -103,29 +101,29 @@ export default {
} }
}, },
'SET_GROUPS' (state, [groups]) { SET_GROUPS(state, [groups]) {
state.groups = groups state.groups = groups
}, },
'ADD_GROUPS' (state, [{ name }]) { ADD_GROUPS(state, [{ name }]) {
if (state.groups !== undefined) { if (state.groups !== undefined) {
Vue.set(state.groups, name, { members: [], permissions: [] }) Vue.set(state.groups, name, { members: [], permissions: [] })
} }
}, },
'UPDATE_GROUPS' (state, [data, { groupName }]) { UPDATE_GROUPS(state, [data, { groupName }]) {
Vue.set(state.groups, groupName, data) Vue.set(state.groups, groupName, data)
}, },
'DEL_GROUPS' (state, [groupname]) { DEL_GROUPS(state, [groupname]) {
Vue.delete(state.groups, groupname) Vue.delete(state.groups, groupname)
}, },
'SET_PERMISSIONS' (state, [permissions]) { SET_PERMISSIONS(state, [permissions]) {
state.permissions = permissions state.permissions = permissions
}, },
'UPDATE_PERMISSIONS' (state, [_, { groupName, action, permId }]) { UPDATE_PERMISSIONS(state, [_, { groupName, action, permId }]) {
// FIXME hacky way to update the store // FIXME hacky way to update the store
const permissions = state.groups[groupName].permissions const permissions = state.groups[groupName].permissions
if (action === 'add') { if (action === 'add') {
@ -134,56 +132,103 @@ export default {
const index = permissions.indexOf(permId) const index = permissions.indexOf(permId)
if (index > -1) permissions.splice(index, 1) if (index > -1) permissions.splice(index, 1)
} }
} },
}, },
actions: { actions: {
'GET' ( GET(
{ state, commit, rootState }, { state, commit, rootState },
{ uri, param, storeKey = uri, humanKey, noCache, options, ...extraParams } {
uri,
param,
storeKey = uri,
humanKey,
noCache,
options,
...extraParams
},
) { ) {
const currentState = param ? state[storeKey][param] : state[storeKey] const currentState = param ? state[storeKey][param] : state[storeKey]
// if data has already been queried, simply return // if data has already been queried, simply return
const ignoreCache = !rootState.cache || noCache || false const ignoreCache = !rootState.cache || noCache || false
if (currentState !== undefined && !ignoreCache) return currentState if (currentState !== undefined && !ignoreCache) return currentState
return api.fetch('GET', param ? `${uri}/${param}` : uri, null, humanKey, options).then(responseData => { return api
// FIXME here's an ugly fix to be able to also cache the main domain when querying domains .fetch('GET', param ? `${uri}/${param}` : uri, null, humanKey, options)
const data = storeKey === 'domains' .then((responseData) => {
? responseData // FIXME here's an ugly fix to be able to also cache the main domain when querying domains
: responseData[storeKey] ? responseData[storeKey] : responseData const data =
commit( storeKey === 'domains'
'SET_' + storeKey.toUpperCase(), ? responseData
[param, data, extraParams].filter(item => !isEmptyValue(item)) : responseData[storeKey]
? responseData[storeKey]
: responseData
commit(
'SET_' + storeKey.toUpperCase(),
[param, data, extraParams].filter((item) => !isEmptyValue(item)),
)
return param ? state[storeKey][param] : state[storeKey]
})
},
POST(
{ state, commit },
{ uri, storeKey = uri, data, humanKey, options, ...extraParams },
) {
return api
.fetch('POST', uri, data, humanKey, options)
.then((responseData) => {
// FIXME api/domains returns null
if (responseData === null) responseData = data
responseData = responseData[storeKey]
? responseData[storeKey]
: responseData
commit(
'ADD_' + storeKey.toUpperCase(),
[responseData, extraParams].filter((item) => !isEmptyValue(item)),
)
return state[storeKey]
})
},
PUT(
{ state, commit },
{ uri, param, storeKey = uri, data, humanKey, options, ...extraParams },
) {
return api
.fetch('PUT', param ? `${uri}/${param}` : uri, data, humanKey, options)
.then((responseData) => {
const data = responseData[storeKey]
? responseData[storeKey]
: responseData
commit(
'UPDATE_' + storeKey.toUpperCase(),
[param, data, extraParams].filter((item) => !isEmptyValue(item)),
)
return param ? state[storeKey][param] : state[storeKey]
})
},
DELETE(
{ commit },
{ uri, param, storeKey = uri, data, humanKey, options, ...extraParams },
) {
return api
.fetch(
'DELETE',
param ? `${uri}/${param}` : uri,
data,
humanKey,
options,
) )
return param ? state[storeKey][param] : state[storeKey] .then(() => {
}) commit(
'DEL_' + storeKey.toUpperCase(),
[param, extraParams].filter((item) => !isEmptyValue(item)),
)
})
}, },
'POST' ({ state, commit }, { uri, storeKey = uri, data, humanKey, options, ...extraParams }) { RESET_CACHE_DATA({ state }, keys = Object.keys(state)) {
return api.fetch('POST', uri, data, humanKey, options).then(responseData => {
// FIXME api/domains returns null
if (responseData === null) responseData = data
responseData = responseData[storeKey] ? responseData[storeKey] : responseData
commit('ADD_' + storeKey.toUpperCase(), [responseData, extraParams].filter(item => !isEmptyValue(item)))
return state[storeKey]
})
},
'PUT' ({ state, commit }, { uri, param, storeKey = uri, data, humanKey, options, ...extraParams }) {
return api.fetch('PUT', param ? `${uri}/${param}` : uri, data, humanKey, options).then(responseData => {
const data = responseData[storeKey] ? responseData[storeKey] : responseData
commit('UPDATE_' + storeKey.toUpperCase(), [param, data, extraParams].filter(item => !isEmptyValue(item)))
return param ? state[storeKey][param] : state[storeKey]
})
},
'DELETE' ({ commit }, { uri, param, storeKey = uri, data, humanKey, options, ...extraParams }) {
return api.fetch('DELETE', param ? `${uri}/${param}` : uri, data, humanKey, options).then(() => {
commit('DEL_' + storeKey.toUpperCase(), [param, extraParams].filter(item => !isEmptyValue(item)))
})
},
'RESET_CACHE_DATA' ({ state }, keys = Object.keys(state)) {
for (const key of keys) { for (const key of keys) {
if (key === 'users_details') { if (key === 'users_details') {
state[key] = {} state[key] = {}
@ -191,36 +236,40 @@ export default {
state[key] = undefined state[key] = undefined
} }
} }
} },
}, },
getters: { getters: {
users: state => { users: (state) => {
if (state.users) return Object.values(state.users) if (state.users) return Object.values(state.users)
return state.users return state.users
}, },
userNames: state => { userNames: (state) => {
if (state.users) return Object.keys(state.users) if (state.users) return Object.keys(state.users)
return [] return []
}, },
user: state => name => state.users_details[name], // not cached user: (state) => (name) => state.users_details[name], // not cached
domains: state => state.domains, domains: (state) => state.domains,
orderedDomains: state => { orderedDomains: (state) => {
if (!state.domains) return if (!state.domains) return
const splittedDomains = Object.fromEntries(state.domains.map(domain => { const splittedDomains = Object.fromEntries(
// Keep the main part of the domain and the extension together state.domains.map((domain) => {
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this'] // Keep the main part of the domain and the extension together
domain = domain.split('.') // eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
domain.push(domain.pop() + domain.pop()) domain = domain.split('.')
return [domain, domain.reverse()] domain.push(domain.pop() + domain.pop())
})) return [domain, domain.reverse()]
}),
)
return state.domains.sort((a, b) => splittedDomains[a] > splittedDomains[b]) return state.domains.sort(
(a, b) => splittedDomains[a] > splittedDomains[b],
)
}, },
domainsTree: (state, getters) => { domainsTree: (state, getters) => {
@ -230,30 +279,33 @@ export default {
// action when state.domain change) // action when state.domain change)
const domains = getters.orderedDomains const domains = getters.orderedDomains
if (!domains) return if (!domains) return
const dataset = domains.map(name => ({ const dataset = domains.map((name) => ({
// data to build a hierarchy // data to build a hierarchy
name, name,
parent: getParentDomain(name, domains), parent: getParentDomain(name, domains),
// utility data that will be used by `RecursiveListGroup` component // utility data that will be used by `RecursiveListGroup` component
to: { name: 'domain-info', params: { name } }, to: { name: 'domain-info', params: { name } },
opened: true opened: true,
})) }))
return stratify(dataset) return stratify(dataset)
}, },
domain: state => name => state.domains_details[name], domain: (state) => (name) => state.domains_details[name],
highestDomainParentName: (state, getters) => name => { highestDomainParentName: (state, getters) => (name) => {
return getParentDomain(name, getters.orderedDomains, true) return getParentDomain(name, getters.orderedDomains, true)
}, },
mainDomain: state => state.main_domain, mainDomain: (state) => state.main_domain,
domainsAsChoices: state => { domainsAsChoices: (state) => {
const mainDomain = state.main_domain const mainDomain = state.main_domain
return state.domains.map(domain => { return state.domains.map((domain) => {
return { value: domain, text: domain === mainDomain ? domain + ' ★' : domain } return {
value: domain,
text: domain === mainDomain ? domain + ' ★' : domain,
}
}) })
} },
} },
} }

View file

@ -14,6 +14,6 @@ export default new Vuex.Store({
getters: settings.getters, getters: settings.getters,
modules: { modules: {
info, info,
data data,
} },
}) })

View file

@ -19,32 +19,32 @@ export default {
tempMessages: [], // Array of messages tempMessages: [], // Array of messages
routerKey: undefined, // String if current route has params routerKey: undefined, // String if current route has params
breadcrumb: [], // Array of routes breadcrumb: [], // Array of routes
transitionName: null // String of CSS class if transitions are enabled transitionName: null, // String of CSS class if transitions are enabled
}, },
mutations: { mutations: {
'SET_INSTALLED' (state, boolean) { SET_INSTALLED(state, boolean) {
state.installed = boolean state.installed = boolean
}, },
'SET_CONNECTED' (state, boolean) { SET_CONNECTED(state, boolean) {
localStorage.setItem('connected', boolean) localStorage.setItem('connected', boolean)
state.connected = boolean state.connected = boolean
}, },
'SET_YUNOHOST_INFOS' (state, yunohost) { SET_YUNOHOST_INFOS(state, yunohost) {
state.yunohost = yunohost state.yunohost = yunohost
}, },
'SET_WAITING' (state, boolean) { SET_WAITING(state, boolean) {
state.waiting = boolean state.waiting = boolean
}, },
'SET_RECONNECTING' (state, args) { SET_RECONNECTING(state, args) {
state.reconnecting = args state.reconnecting = args
}, },
'ADD_REQUEST' (state, request) { ADD_REQUEST(state, request) {
if (state.requests.length > 10) { if (state.requests.length > 10) {
// We do not remove requests right after it resolves since an error might bring // 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. // one back to life but we can safely remove some here.
@ -53,35 +53,38 @@ export default {
state.requests.push(request) state.requests.push(request)
}, },
'UPDATE_REQUEST' (state, { request, key, value }) { UPDATE_REQUEST(state, { request, key, value }) {
// This rely on data persistance and reactivity. // This rely on data persistance and reactivity.
Vue.set(request, key, value) Vue.set(request, key, value)
}, },
'REMOVE_REQUEST' (state, request) { REMOVE_REQUEST(state, request) {
const index = state.requests.lastIndexOf(request) const index = state.requests.lastIndexOf(request)
state.requests.splice(index, 1) state.requests.splice(index, 1)
}, },
'ADD_HISTORY_ACTION' (state, request) { ADD_HISTORY_ACTION(state, request) {
state.history.push(request) state.history.push(request)
}, },
'ADD_TEMP_MESSAGE' (state, { request, message, type }) { ADD_TEMP_MESSAGE(state, { request, message, type }) {
state.tempMessages.push([message, type]) state.tempMessages.push([message, type])
}, },
'UPDATE_DISPLAYED_MESSAGES' (state, { request }) { UPDATE_DISPLAYED_MESSAGES(state, { request }) {
if (!state.tempMessages.length) { if (!state.tempMessages.length) {
state.historyTimer = null state.historyTimer = null
return return
} }
const { messages, warnings, errors } = state.tempMessages.reduce((acc, [message, type]) => { const { messages, warnings, errors } = state.tempMessages.reduce(
acc.messages.push(message) (acc, [message, type]) => {
if (['error', 'warning'].includes(type)) acc[type + 's']++ acc.messages.push(message)
return acc if (['error', 'warning'].includes(type)) acc[type + 's']++
}, { messages: [], warnings: 0, errors: 0 }) return acc
},
{ messages: [], warnings: 0, errors: 0 },
)
state.tempMessages = [] state.tempMessages = []
state.historyTimer = null state.historyTimer = null
request.messages = request.messages.concat(messages) request.messages = request.messages.concat(messages)
@ -89,7 +92,7 @@ export default {
request.errors += errors request.errors += errors
}, },
'SET_ERROR' (state, request) { SET_ERROR(state, request) {
if (request) { if (request) {
state.error = request state.error = request
} else { } else {
@ -97,21 +100,21 @@ export default {
} }
}, },
'SET_ROUTER_KEY' (state, key) { SET_ROUTER_KEY(state, key) {
state.routerKey = key state.routerKey = key
}, },
'SET_BREADCRUMB' (state, breadcrumb) { SET_BREADCRUMB(state, breadcrumb) {
state.breadcrumb = breadcrumb state.breadcrumb = breadcrumb
}, },
'SET_TRANSITION_NAME' (state, transitionName) { SET_TRANSITION_NAME(state, transitionName) {
state.transitionName = transitionName state.transitionName = transitionName
} },
}, },
actions: { actions: {
async 'ON_APP_CREATED' ({ dispatch, state }) { async ON_APP_CREATED({ dispatch, state }) {
await dispatch('CHECK_INSTALL') await dispatch('CHECK_INSTALL')
if (!state.installed) { if (!state.installed) {
@ -121,7 +124,7 @@ export default {
} }
}, },
async 'CHECK_INSTALL' ({ dispatch, commit }, retry = 2) { async CHECK_INSTALL({ dispatch, commit }, retry = 2) {
// this action will try to query the `/installed` route 3 times every 5 s with // this action will try to query the `/installed` route 3 times every 5 s with
// a timeout of the same delay. // a timeout of the same delay.
// FIXME need testing with api not responding // FIXME need testing with api not responding
@ -137,7 +140,7 @@ export default {
} }
}, },
async 'CONNECT' ({ commit, dispatch }) { async CONNECT({ commit, dispatch }) {
// If the user is not connected, the first action will throw // If the user is not connected, the first action will throw
// and login prompt will be shown automaticly // and login prompt will be shown automaticly
await dispatch('GET_YUNOHOST_INFOS') await dispatch('GET_YUNOHOST_INFOS')
@ -145,54 +148,77 @@ export default {
await dispatch('GET', { uri: 'domains', storeKey: 'domains' }) await dispatch('GET', { uri: 'domains', storeKey: 'domains' })
}, },
'RESET_CONNECTED' ({ commit }) { RESET_CONNECTED({ commit }) {
commit('SET_CONNECTED', false) commit('SET_CONNECTED', false)
commit('SET_YUNOHOST_INFOS', null) commit('SET_YUNOHOST_INFOS', null)
}, },
'DISCONNECT' ({ dispatch }, route = router.currentRoute) { DISCONNECT({ dispatch }, route = router.currentRoute) {
dispatch('RESET_CONNECTED') dispatch('RESET_CONNECTED')
if (router.currentRoute.name === 'login') return if (router.currentRoute.name === 'login') return
router.push({ router.push({
name: 'login', name: 'login',
// Add a redirect query if next route is not unknown (like `logout`) or `login` // Add a redirect query if next route is not unknown (like `logout`) or `login`
query: route && !['login', null].includes(route.name) query:
? { redirect: route.path } route && !['login', null].includes(route.name)
: {} ? { redirect: route.path }
: {},
}) })
}, },
'LOGIN' ({ dispatch }, credentials) { LOGIN({ dispatch }, credentials) {
return api.post('login', { credentials }, null, { websocket: false }).then(() => { return api
dispatch('CONNECT') .post('login', { credentials }, null, { websocket: false })
}) .then(() => {
dispatch('CONNECT')
})
}, },
'LOGOUT' ({ dispatch }) { LOGOUT({ dispatch }) {
dispatch('DISCONNECT') dispatch('DISCONNECT')
return api.get('logout') return api.get('logout')
}, },
'TRY_TO_RECONNECT' ({ commit, dispatch }, args = {}) { TRY_TO_RECONNECT({ commit, dispatch }, args = {}) {
// FIXME This is very ugly arguments forwarding, will use proper component way of doing this when switching to Vue 3 (teleport) // FIXME This is very ugly arguments forwarding, will use proper component way of doing this when switching to Vue 3 (teleport)
commit('SET_RECONNECTING', args) commit('SET_RECONNECTING', args)
dispatch('RESET_CONNECTED') dispatch('RESET_CONNECTED')
}, },
'GET_YUNOHOST_INFOS' ({ commit }) { GET_YUNOHOST_INFOS({ commit }) {
return api.get('versions').then(versions => { return api.get('versions').then((versions) => {
commit('SET_YUNOHOST_INFOS', versions.yunohost) commit('SET_YUNOHOST_INFOS', versions.yunohost)
}) })
}, },
'INIT_REQUEST' ({ commit }, { method, uri, humanKey, initial, wait, websocket }) { INIT_REQUEST(
{ commit },
{ method, uri, humanKey, initial, wait, websocket },
) {
// Try to find a description for an API route to display in history and modals // Try to find a description for an API route to display in history and modals
const { key, ...args } = isObjectLiteral(humanKey) ? humanKey : { key: humanKey } const { key, ...args } = isObjectLiteral(humanKey)
const humanRoute = key ? i18n.t('human_routes.' + key, args) : `[${method}] /${uri}` ? humanKey
: { key: humanKey }
const humanRoute = key
? i18n.t('human_routes.' + key, args)
: `[${method}] /${uri}`
let request = { method, uri, humanRouteKey: key, humanRoute, initial, status: 'pending' } let request = {
method,
uri,
humanRouteKey: key,
humanRoute,
initial,
status: 'pending',
}
if (websocket) { if (websocket) {
request = { ...request, messages: [], date: Date.now(), warnings: 0, errors: 0 } request = {
...request,
messages: [],
date: Date.now(),
warnings: 0,
errors: 0,
}
commit('ADD_HISTORY_ACTION', request) commit('ADD_HISTORY_ACTION', request)
} }
commit('ADD_REQUEST', request) commit('ADD_REQUEST', request)
@ -208,7 +234,7 @@ export default {
return request return request
}, },
'END_REQUEST' ({ state, commit }, { request, success, wait }) { END_REQUEST({ state, commit }, { request, success, wait }) {
// Update last messages before finishing this request // Update last messages before finishing this request
clearTimeout(state.historyTimer) clearTimeout(state.historyTimer)
commit('UPDATE_DISPLAYED_MESSAGES', { request }) commit('UPDATE_DISPLAYED_MESSAGES', { request })
@ -216,7 +242,10 @@ export default {
let status = success ? 'success' : 'error' let status = success ? 'success' : 'error'
if (success && (request.warnings || request.errors)) { if (success && (request.warnings || request.errors)) {
const messages = request.messages const messages = request.messages
if (messages.length && messages[messages.length - 1].color === 'warning') { if (
messages.length &&
messages[messages.length - 1].color === 'warning'
) {
request.showWarningMessage = true request.showWarningMessage = true
} }
status = 'warning' status = 'warning'
@ -231,11 +260,11 @@ export default {
} }
}, },
'DISPATCH_MESSAGE' ({ state, commit, dispatch }, { request, messages }) { DISPATCH_MESSAGE({ state, commit, dispatch }, { request, messages }) {
for (const type in messages) { for (const type in messages) {
const message = { const message = {
text: messages[type].replaceAll('\n', '<br>'), text: messages[type].replaceAll('\n', '<br>'),
color: type === 'error' ? 'danger' : type color: type === 'error' ? 'danger' : type,
} }
let progressBar = message.text.match(/^\[#*\+*\.*\] > /) let progressBar = message.text.match(/^\[#*\+*\.*\] > /)
if (progressBar) { if (progressBar) {
@ -245,7 +274,11 @@ export default {
for (const char of progressBar) { for (const char of progressBar) {
if (char in progress) progress[char] += 1 if (char in progress) progress[char] += 1
} }
commit('UPDATE_REQUEST', { request, key: 'progress', value: Object.values(progress) }) commit('UPDATE_REQUEST', {
request,
key: 'progress',
value: Object.values(progress),
})
} }
if (message.text) { if (message.text) {
// To avoid rendering lag issues, limit the flow of websocket messages to batches of 50ms. // To avoid rendering lag issues, limit the flow of websocket messages to batches of 50ms.
@ -259,7 +292,7 @@ export default {
} }
}, },
'HANDLE_ERROR' ({ commit, dispatch }, error) { HANDLE_ERROR({ commit, dispatch }, error) {
if (error.code === 401) { if (error.code === 401) {
// Unauthorized // Unauthorized
dispatch('DISCONNECT') dispatch('DISCONNECT')
@ -277,12 +310,12 @@ export default {
} }
}, },
'REVIEW_ERROR' ({ commit }, request) { REVIEW_ERROR({ commit }, request) {
request.review = true request.review = true
commit('SET_ERROR', request) commit('SET_ERROR', request)
}, },
'DISMISS_ERROR' ({ commit, state }, { initial, review = false }) { DISMISS_ERROR({ commit, state }, { initial, review = false }) {
if (initial && !review) { if (initial && !review) {
// In case of an initial request (data that is needed by a view to render itself), // 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. // try to go back so the user doesn't get stuck at a never ending skeleton view.
@ -296,12 +329,12 @@ export default {
commit('SET_ERROR', null) commit('SET_ERROR', null)
}, },
'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 }) { UPDATE_ROUTER_KEY({ commit }, { to, from }) {
if (isEmptyValue(to.params)) { if (isEmptyValue(to.params)) {
commit('SET_ROUTER_KEY', undefined) commit('SET_ROUTER_KEY', undefined)
return return
@ -313,21 +346,24 @@ export default {
// Params can be declared in route `meta` to stricly define which params should be // Params can be declared in route `meta` to stricly define which params should be
// taken into account. // taken into account.
const params = to.meta.routerParams const params = to.meta.routerParams
? to.meta.routerParams.map(key => to.params[key]) ? to.meta.routerParams.map((key) => to.params[key])
: Object.values(to.params) : Object.values(to.params)
commit('SET_ROUTER_KEY', `${to.name}-${params.join('-')}`) commit('SET_ROUTER_KEY', `${to.name}-${params.join('-')}`)
}, },
'UPDATE_BREADCRUMB' ({ commit }, { to, from }) { UPDATE_BREADCRUMB({ commit }, { to, from }) {
function getRouteNames (route) { function getRouteNames(route) {
if (route.meta.breadcrumb) return route.meta.breadcrumb if (route.meta.breadcrumb) return route.meta.breadcrumb
const parentRoute = route.matched.slice().reverse().find(route => route.meta.breadcrumb) const parentRoute = route.matched
.slice()
.reverse()
.find((route) => route.meta.breadcrumb)
if (parentRoute) return parentRoute.meta.breadcrumb if (parentRoute) return parentRoute.meta.breadcrumb
return [] return []
} }
function formatRoute (route) { function formatRoute(route) {
const { trad, param } = route.meta.args || {} const { trad, param } = route.meta.args || {}
let text = '' let text = ''
// if a traduction key string has been given and we also need to pass // if a traduction key string has been given and we also need to pass
@ -344,49 +380,55 @@ export default {
const routeNames = getRouteNames(to) const routeNames = getRouteNames(to)
const allRoutes = router.getRoutes() const allRoutes = router.getRoutes()
const breadcrumb = routeNames.map(name => { const breadcrumb = routeNames.map((name) => {
const route = allRoutes.find(route => route.name === name) const route = allRoutes.find((route) => route.name === name)
return formatRoute(route) return formatRoute(route)
}) })
commit('SET_BREADCRUMB', breadcrumb) commit('SET_BREADCRUMB', breadcrumb)
function getTitle (breadcrumb) { function getTitle(breadcrumb) {
if (breadcrumb.length === 0) return formatRoute(to).text if (breadcrumb.length === 0) return formatRoute(to).text
return (breadcrumb.length > 2 ? breadcrumb.slice(-2) : breadcrumb).map(route => route.text).reverse().join(' / ') return (breadcrumb.length > 2 ? breadcrumb.slice(-2) : breadcrumb)
.map((route) => route.text)
.reverse()
.join(' / ')
} }
// Display a simplified breadcrumb as the document title. // Display a simplified breadcrumb as the document title.
document.title = `${getTitle(breadcrumb)} | ${i18n.t('yunohost_admin')}` document.title = `${getTitle(breadcrumb)} | ${i18n.t('yunohost_admin')}`
}, },
'UPDATE_TRANSITION_NAME' ({ state, commit }, { to, from }) { UPDATE_TRANSITION_NAME({ state, commit }, { to, from }) {
// Use the breadcrumb array length as a direction indicator // Use the breadcrumb array length as a direction indicator
const toDepth = (to.meta.breadcrumb || []).length const toDepth = (to.meta.breadcrumb || []).length
const fromDepth = (from.meta.breadcrumb || []).length const fromDepth = (from.meta.breadcrumb || []).length
commit('SET_TRANSITION_NAME', toDepth < fromDepth ? 'slide-right' : 'slide-left') commit(
} 'SET_TRANSITION_NAME',
toDepth < fromDepth ? 'slide-right' : 'slide-left',
)
},
}, },
getters: { getters: {
host: state => state.host, host: (state) => state.host,
installed: state => state.installed, installed: (state) => state.installed,
connected: state => state.connected, connected: (state) => state.connected,
yunohost: state => state.yunohost, yunohost: (state) => state.yunohost,
error: state => state.error, error: (state) => state.error,
waiting: state => state.waiting, waiting: (state) => state.waiting,
reconnecting: state => state.reconnecting, reconnecting: (state) => state.reconnecting,
history: state => state.history, history: (state) => state.history,
lastAction: state => state.history[state.history.length - 1], lastAction: (state) => state.history[state.history.length - 1],
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, routerKey: (state) => state.routerKey,
breadcrumb: state => state.breadcrumb, breadcrumb: (state) => state.breadcrumb,
transitionName: state => state.transitionName, transitionName: (state) => state.transitionName,
ssoLink: (state, getters) => { ssoLink: (state, getters) => {
return `//${getters.mainDomain ?? state.host}/yunohost/sso` return `//${getters.mainDomain ?? state.host}/yunohost/sso`
} },
} },
} }

View file

@ -4,7 +4,11 @@
*/ */
import i18n from '@/i18n' import i18n from '@/i18n'
import { loadLocaleMessages, updateDocumentLocale, loadDateFnsLocale } from '@/i18n/helpers' import {
loadLocaleMessages,
updateDocumentLocale,
loadDateFnsLocale,
} from '@/i18n/helpers'
import supportedLocales from '@/i18n/supportedLocales' import supportedLocales from '@/i18n/supportedLocales'
export default { export default {
@ -16,48 +20,48 @@ export default {
theme: localStorage.getItem('theme') === 'true', theme: localStorage.getItem('theme') === 'true',
experimental: localStorage.getItem('experimental') === 'true', experimental: localStorage.getItem('experimental') === 'true',
spinner: 'pacman', spinner: 'pacman',
supportedLocales supportedLocales,
}, },
mutations: { mutations: {
'SET_LOCALE' (state, locale) { SET_LOCALE(state, locale) {
localStorage.setItem('locale', locale) localStorage.setItem('locale', locale)
state.locale = locale state.locale = locale
}, },
'SET_FALLBACKLOCALE' (state, locale) { SET_FALLBACKLOCALE(state, locale) {
localStorage.setItem('fallbackLocale', locale) localStorage.setItem('fallbackLocale', locale)
state.fallbackLocale = locale state.fallbackLocale = locale
}, },
'SET_CACHE' (state, boolean) { SET_CACHE(state, boolean) {
localStorage.setItem('cache', boolean) localStorage.setItem('cache', boolean)
state.cache = boolean state.cache = boolean
}, },
'SET_TRANSITIONS' (state, boolean) { SET_TRANSITIONS(state, boolean) {
localStorage.setItem('transitions', boolean) localStorage.setItem('transitions', boolean)
state.transitions = boolean state.transitions = boolean
}, },
'SET_EXPERIMENTAL' (state, boolean) { SET_EXPERIMENTAL(state, boolean) {
localStorage.setItem('experimental', boolean) localStorage.setItem('experimental', boolean)
state.experimental = boolean state.experimental = boolean
}, },
'SET_SPINNER' (state, spinner) { SET_SPINNER(state, spinner) {
state.spinner = spinner state.spinner = spinner
}, },
'SET_THEME' (state, boolean) { SET_THEME(state, boolean) {
localStorage.setItem('theme', boolean) localStorage.setItem('theme', boolean)
state.theme = boolean state.theme = boolean
document.documentElement.setAttribute('dark-theme', boolean) document.documentElement.setAttribute('dark-theme', boolean)
} },
}, },
actions: { actions: {
'UPDATE_LOCALE' ({ commit }, locale) { UPDATE_LOCALE({ commit }, locale) {
loadLocaleMessages(locale).then(() => { loadLocaleMessages(locale).then(() => {
updateDocumentLocale(locale) updateDocumentLocale(locale)
commit('SET_LOCALE', locale) commit('SET_LOCALE', locale)
@ -67,31 +71,33 @@ export default {
loadDateFnsLocale(locale) loadDateFnsLocale(locale)
}, },
'UPDATE_FALLBACKLOCALE' ({ commit }, locale) { UPDATE_FALLBACKLOCALE({ commit }, locale) {
loadLocaleMessages(locale).then(() => { loadLocaleMessages(locale).then(() => {
commit('SET_FALLBACKLOCALE', locale) commit('SET_FALLBACKLOCALE', locale)
i18n.fallbackLocale = [locale, 'en'] i18n.fallbackLocale = [locale, 'en']
}) })
}, },
'UPDATE_THEME' ({ commit }, theme) { UPDATE_THEME({ commit }, theme) {
commit('SET_THEME', theme) commit('SET_THEME', theme)
} },
}, },
getters: { getters: {
locale: state => (state.locale), locale: (state) => state.locale,
fallbackLocale: state => (state.fallbackLocale), fallbackLocale: (state) => state.fallbackLocale,
cache: state => (state.cache), cache: (state) => state.cache,
transitions: state => (state.transitions), transitions: (state) => state.transitions,
theme: state => (state.theme), theme: (state) => state.theme,
experimental: state => state.experimental, experimental: (state) => state.experimental,
spinner: state => state.spinner, spinner: (state) => state.spinner,
availableLocales: state => { availableLocales: (state) => {
return Object.entries(state.supportedLocales).map(([locale, { name }]) => { return Object.entries(state.supportedLocales).map(
return { value: locale, text: name } ([locale, { name }]) => {
}) return { value: locale, text: name }
} },
} )
},
},
} }

View file

@ -18,7 +18,7 @@
export default { export default {
name: 'HomeView', name: 'HomeView',
data () { data() {
return { return {
menu: [ menu: [
{ routeName: 'user-list', icon: 'users', translation: 'users' }, { routeName: 'user-list', icon: 'users', translation: 'users' },
@ -26,11 +26,15 @@ export default {
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' }, { routeName: 'app-list', icon: 'cubes', translation: 'applications' },
{ routeName: 'update', icon: 'refresh', translation: 'system_update' }, { routeName: 'update', icon: 'refresh', translation: 'system_update' },
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' }, { routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
{ routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' }, {
{ routeName: 'backup', icon: 'archive', translation: 'backup' } routeName: 'diagnosis',
] icon: 'stethoscope',
translation: 'diagnosis',
},
{ routeName: 'backup', icon: 'archive', translation: 'backup' },
],
} }
} },
} }
</script> </script>

View file

@ -1,19 +1,31 @@
<template> <template>
<CardForm <CardForm
:title="$t('login')" icon="lock" :title="$t('login')"
:validation="$v" :server-error="serverError" icon="lock"
:validation="$v"
:server-error="serverError"
@submit.prevent="login" @submit.prevent="login"
> >
<!-- ADMIN USERNAME --> <!-- ADMIN USERNAME -->
<FormField v-bind="fields.username" v-model="form.username" :validation="$v.form.username" /> <FormField
v-bind="fields.username"
v-model="form.username"
:validation="$v.form.username"
/>
<!-- ADMIN PASSWORD --> <!-- ADMIN PASSWORD -->
<FormField v-bind="fields.password" v-model="form.password" :validation="$v.form.password" /> <FormField
v-bind="fields.password"
v-model="form.password"
:validation="$v.form.password"
/>
<template #buttons> <template #buttons>
<BButton <BButton
type="submit" variant="success" type="submit"
:disabled="!installed" form="ynh-form" variant="success"
:disabled="!installed"
form="ynh-form"
> >
{{ $t('login') }} {{ $t('login') }}
</BButton> </BButton>
@ -32,63 +44,68 @@ export default {
mixins: [validationMixin], mixins: [validationMixin],
props: { props: {
forceReload: { type: Boolean, default: false } forceReload: { type: Boolean, default: false },
}, },
data () { data() {
return { return {
serverError: '', serverError: '',
form: { form: {
username: '', username: '',
password: '' password: '',
}, },
fields: { fields: {
username: { username: {
label: this.$i18n.t('user_username'), label: this.$i18n.t('user_username'),
props: { props: {
id: 'username', id: 'username',
autocomplete: 'username' autocomplete: 'username',
} },
}, },
password: { password: {
label: this.$i18n.t('password'), label: this.$i18n.t('password'),
props: { props: {
id: 'password', id: 'password',
type: 'password', type: 'password',
autocomplete: 'current-password' autocomplete: 'current-password',
} },
} },
} },
} }
}, },
computed: { computed: {
...mapGetters(['installed']) ...mapGetters(['installed']),
}, },
validations () { validations() {
return { return {
form: { form: {
username: { required, alphalownumdot_ }, username: { required, alphalownumdot_ },
password: { required, passwordLenght: minLength(4) } password: { required, passwordLenght: minLength(4) },
} },
} }
}, },
methods: { methods: {
login () { login() {
const credentials = [this.form.username, this.form.password].join(':') const credentials = [this.form.username, this.form.password].join(':')
this.$store.dispatch('LOGIN', credentials).then(() => { this.$store
if (this.forceReload) { .dispatch('LOGIN', credentials)
window.location.href = '/yunohost/admin/' .then(() => {
} else { if (this.forceReload) {
this.$router.push(this.$router.currentRoute.query.redirect || { name: 'home' }) window.location.href = '/yunohost/admin/'
} } else {
}).catch(err => { this.$router.push(
if (err.name !== 'APIUnauthorizedError') throw err this.$router.currentRoute.query.redirect || { name: 'home' },
this.serverError = this.$i18n.t('wrong_password_or_username') )
}) }
} })
} .catch((err) => {
if (err.name !== 'APIUnauthorizedError') throw err
this.serverError = this.$i18n.t('wrong_password_or_username')
})
},
},
} }
</script> </script>

View file

@ -8,7 +8,7 @@
<p class="alert alert-info"> <p class="alert alert-info">
<span v-t="'postinstall_intro_2'" /> <span v-t="'postinstall_intro_2'" />
<br> <br />
<span v-html="$t('postinstall_intro_3')" /> <span v-html="$t('postinstall_intro_3')" />
</p> </p>
@ -20,7 +20,9 @@
<!-- DOMAIN SETUP STEP --> <!-- DOMAIN SETUP STEP -->
<template v-else-if="step === 'domain'"> <template v-else-if="step === 'domain'">
<DomainForm <DomainForm
:title="$t('postinstall_set_domain')" :submit-text="$t('next')" :server-error="serverError" :title="$t('postinstall_set_domain')"
:submit-text="$t('next')"
:server-error="serverError"
@submit="setDomain" @submit="setDomain"
> >
<template #disclaimer> <template #disclaimer>
@ -36,9 +38,12 @@
<!-- FIRST USER SETUP STEP --> <!-- FIRST USER SETUP STEP -->
<template v-else-if="step === 'user'"> <template v-else-if="step === 'user'">
<CardForm <CardForm
:title="$t('postinstall.user.title')" icon="user-plus" :title="$t('postinstall.user.title')"
:validation="$v" :server-error="serverError" icon="user-plus"
:submit-text="$t('next')" @submit.prevent="setUser" :validation="$v"
:server-error="serverError"
:submit-text="$t('next')"
@submit.prevent="setUser"
> >
<ReadOnlyAlertItem <ReadOnlyAlertItem
:label="$t('postinstall.user.first_user_help')" :label="$t('postinstall.user.first_user_help')"
@ -46,8 +51,11 @@
/> />
<FormField <FormField
v-for="(field, name) in fields" :key="name" v-for="(field, name) in fields"
v-bind="field" v-model="user[name]" :validation="$v.user[name]" :key="name"
v-bind="field"
v-model="user[name]"
:validation="$v.user[name]"
/> />
</CardForm> </CardForm>
@ -87,7 +95,13 @@ import api from '@/api'
import { DomainForm } from '@/views/_partials' import { DomainForm } from '@/views/_partials'
import LoginView from '@/views/LoginView.vue' import LoginView from '@/views/LoginView.vue'
import { formatFormData } from '@/helpers/yunohostArguments' import { formatFormData } from '@/helpers/yunohostArguments'
import { alphalownumdot_, required, minLength, name, sameAs } from '@/helpers/validators' import {
alphalownumdot_,
required,
minLength,
name,
sameAs,
} from '@/helpers/validators'
export default { export default {
name: 'PostInstall', name: 'PostInstall',
@ -96,10 +110,10 @@ export default {
components: { components: {
DomainForm, DomainForm,
LoginView LoginView,
}, },
data () { data() {
return { return {
step: 'start', step: 'start',
serverError: '', serverError: '',
@ -109,98 +123,110 @@ export default {
username: '', username: '',
fullname: '', fullname: '',
password: '', password: '',
confirmation: '' confirmation: '',
}, },
fields: { fields: {
username: { username: {
label: this.$i18n.t('user_username'), label: this.$i18n.t('user_username'),
props: { id: 'username', placeholder: this.$i18n.t('placeholder.username') } props: {
id: 'username',
placeholder: this.$i18n.t('placeholder.username'),
},
}, },
fullname: { fullname: {
label: this.$i18n.t('user_fullname'), label: this.$i18n.t('user_fullname'),
props: { id: 'fullname', placeholder: this.$i18n.t('placeholder.fullname') } props: {
id: 'fullname',
placeholder: this.$i18n.t('placeholder.fullname'),
},
}, },
password: { password: {
label: this.$i18n.t('password'), label: this.$i18n.t('password'),
description: this.$i18n.t('good_practices_about_admin_password'), description: this.$i18n.t('good_practices_about_admin_password'),
descriptionVariant: 'warning', descriptionVariant: 'warning',
props: { id: 'password', placeholder: '••••••••', type: 'password' } props: { id: 'password', placeholder: '••••••••', type: 'password' },
}, },
confirmation: { confirmation: {
label: this.$i18n.t('password_confirmation'), label: this.$i18n.t('password_confirmation'),
props: { id: 'confirmation', placeholder: '••••••••', type: 'password' } props: {
} id: 'confirmation',
} placeholder: '••••••••',
type: 'password',
},
},
},
} }
}, },
methods: { methods: {
goToStep (step) { goToStep(step) {
this.serverError = '' this.serverError = ''
this.step = step this.step = step
}, },
setDomain ({ domain, dyndns_recovery_password }) { setDomain({ domain, dyndns_recovery_password }) {
this.domain = domain this.domain = domain
this.dyndns_recovery_password = dyndns_recovery_password this.dyndns_recovery_password = dyndns_recovery_password
this.goToStep('user') this.goToStep('user')
}, },
async setUser () { async setUser() {
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_postinstall', { domain: this.domain }) this.$i18n.t('confirm_postinstall', { domain: this.domain }),
) )
if (!confirmed) return if (!confirmed) return
this.performPostInstall() this.performPostInstall()
}, },
async performPostInstall (force = false) { async performPostInstall(force = false) {
const data = await formatFormData({ const data = await formatFormData({
domain: this.domain, domain: this.domain,
dyndns_recovery_password: this.dyndns_recovery_password, dyndns_recovery_password: this.dyndns_recovery_password,
username: this.user.username, username: this.user.username,
fullname: this.user.fullname, fullname: this.user.fullname,
password: this.user.password password: this.user.password,
}) })
// FIXME does the api will throw an error for bad passwords ? // FIXME does the api will throw an error for bad passwords ?
api.post( api
'postinstall' + (force ? '?force_diskspace' : ''), .post('postinstall' + (force ? '?force_diskspace' : ''), data, {
data, key: 'postinstall',
{ key: 'postinstall' } })
).then(() => { .then(() => {
// Display success message and allow the user to login // Display success message and allow the user to login
this.goToStep('login') this.goToStep('login')
}).catch(err => { })
const hasWordsInError = (words) => words.some((word) => (err.key || err.message).includes(word)) .catch((err) => {
if (err.name !== 'APIBadRequestError') throw err const hasWordsInError = (words) =>
if (err.key === 'postinstall_low_rootfsspace') { words.some((word) => (err.key || err.message).includes(word))
this.step = 'rootfsspace-error' if (err.name !== 'APIBadRequestError') throw err
} else if (hasWordsInError(['domain', 'dyndns'])) { if (err.key === 'postinstall_low_rootfsspace') {
this.step = 'domain' this.step = 'rootfsspace-error'
} else if (hasWordsInError(['password', 'user'])) { } else if (hasWordsInError(['domain', 'dyndns'])) {
this.step = 'user' this.step = 'domain'
} else { } else if (hasWordsInError(['password', 'user'])) {
throw err this.step = 'user'
} } else {
this.serverError = err.message throw err
}) }
} this.serverError = err.message
})
},
}, },
validations () { validations() {
return { return {
user: { user: {
username: { required, alphalownumdot_ }, username: { required, alphalownumdot_ },
fullname: { required, name }, fullname: { required, name },
password: { required, passwordLenght: minLength(8) }, password: { required, passwordLenght: minLength(8) },
confirmation: { required, passwordMatch: sameAs('password') } confirmation: { required, passwordMatch: sameAs('password') },
} },
} }
} },
} }
</script> </script>

View file

@ -1,7 +1,10 @@
<template> <template>
<CardForm <CardForm
:title="title" icon="globe" :submit-text="submitText" :title="title"
:validation="$v" :server-error="serverError" icon="globe"
:submit-text="submitText"
:validation="$v"
:server-error="serverError"
@submit.prevent="onSubmit" @submit.prevent="onSubmit"
> >
<template #disclaimer> <template #disclaimer>
@ -9,7 +12,9 @@
</template> </template>
<BFormRadio <BFormRadio
v-model="selected" name="domain-type" value="domain" v-model="selected"
name="domain-type"
value="domain"
:class="domainIsVisible ? null : 'collapsed'" :class="domainIsVisible ? null : 'collapsed'"
:aria-expanded="domainIsVisible ? 'true' : 'false'" :aria-expanded="domainIsVisible ? 'true' : 'false'"
aria-controls="collapse-domain" aria-controls="collapse-domain"
@ -25,13 +30,17 @@
</p> </p>
<FormField <FormField
v-bind="fields.domain" v-model="form.domain" v-bind="fields.domain"
:validation="$v.form.domain" class="mt-3" v-model="form.domain"
:validation="$v.form.domain"
class="mt-3"
/> />
</BCollapse> </BCollapse>
<BFormRadio <BFormRadio
v-model="selected" name="domain-type" value="dynDomain" v-model="selected"
name="domain-type"
value="dynDomain"
:disabled="dynDnsForbiden" :disabled="dynDnsForbiden"
:class="dynDomainIsVisible ? null : 'collapsed'" :class="dynDomainIsVisible ? null : 'collapsed'"
:aria-expanded="dynDomainIsVisible ? 'true' : 'false'" :aria-expanded="dynDomainIsVisible ? 'true' : 'false'"
@ -47,7 +56,11 @@
<span class="pl-1" v-html="$t('domain.add.from_yunohost_desc')" /> <span class="pl-1" v-html="$t('domain.add.from_yunohost_desc')" />
</p> </p>
<FormField v-bind="fields.dynDomain" :validation="$v.form.dynDomain" class="mt-3"> <FormField
v-bind="fields.dynDomain"
:validation="$v.form.dynDomain"
class="mt-3"
>
<template #default="{ self }"> <template #default="{ self }">
<AdressInputSelect v-bind="self" v-model="form.dynDomain" /> <AdressInputSelect v-bind="self" v-model="form.dynDomain" />
</template> </template>
@ -65,10 +78,16 @@
v-model="form.dynDomainPasswordConfirmation" v-model="form.dynDomainPasswordConfirmation"
/> />
</BCollapse> </BCollapse>
<div v-if="dynDnsForbiden" class="alert alert-warning mt-2" v-html="$t('domain_add_dyndns_forbidden')" /> <div
v-if="dynDnsForbiden"
class="alert alert-warning mt-2"
v-html="$t('domain_add_dyndns_forbidden')"
/>
<BFormRadio <BFormRadio
v-model="selected" name="domain-type" value="localDomain" v-model="selected"
name="domain-type"
value="localDomain"
:class="localDomainIsVisible ? null : 'collapsed'" :class="localDomainIsVisible ? null : 'collapsed'"
:aria-expanded="localDomainIsVisible ? 'true' : 'false'" :aria-expanded="localDomainIsVisible ? 'true' : 'false'"
aria-controls="collapse-localDomain" aria-controls="collapse-localDomain"
@ -82,7 +101,11 @@
<span class="pl-1" v-html="$t('domain.add.from_local_desc')" /> <span class="pl-1" v-html="$t('domain.add.from_local_desc')" />
</p> </p>
<FormField v-bind="fields.localDomain" :validation="$v.form.localDomain" class="mt-3"> <FormField
v-bind="fields.localDomain"
:validation="$v.form.localDomain"
class="mt-3"
>
<template #default="{ self }"> <template #default="{ self }">
<AdressInputSelect v-bind="self" v-model="form.localDomain" /> <AdressInputSelect v-bind="self" v-model="form.localDomain" />
</template> </template>
@ -97,7 +120,13 @@ import { validationMixin } from 'vuelidate'
import AdressInputSelect from '@/components/AdressInputSelect.vue' import AdressInputSelect from '@/components/AdressInputSelect.vue'
import { formatFormData } from '@/helpers/yunohostArguments' import { formatFormData } from '@/helpers/yunohostArguments'
import { required, domain, dynDomain, minLength, sameAs } from '@/helpers/validators' import {
required,
domain,
dynDomain,
minLength,
sameAs,
} from '@/helpers/validators'
export default { export default {
name: 'DomainForm', name: 'DomainForm',
@ -105,10 +134,10 @@ export default {
props: { props: {
title: { type: String, required: true }, title: { type: String, required: true },
submitText: { type: String, default: null }, submitText: { type: String, default: null },
serverError: { type: String, default: '' } serverError: { type: String, default: '' },
}, },
data () { data() {
return { return {
selected: '', selected: '',
@ -117,7 +146,7 @@ export default {
dynDomain: { localPart: '', separator: '.', domain: 'nohost.me' }, dynDomain: { localPart: '', separator: '.', domain: 'nohost.me' },
dynDomainPassword: '', dynDomainPassword: '',
dynDomainPasswordConfirmation: '', dynDomainPasswordConfirmation: '',
localDomain: { localPart: '', separator: '.', domain: 'local' } localDomain: { localPart: '', separator: '.', domain: 'local' },
}, },
fields: { fields: {
@ -125,8 +154,8 @@ export default {
label: this.$i18n.t('domain_name'), label: this.$i18n.t('domain_name'),
props: { props: {
id: 'domain', id: 'domain',
placeholder: this.$i18n.t('placeholder.domain') placeholder: this.$i18n.t('placeholder.domain'),
} },
}, },
dynDomain: { dynDomain: {
@ -135,8 +164,8 @@ export default {
id: 'dyn-domain', id: 'dyn-domain',
placeholder: this.$i18n.t('placeholder.domain').split('.')[0], placeholder: this.$i18n.t('placeholder.domain').split('.')[0],
type: 'domain', type: 'domain',
choices: ['nohost.me', 'noho.st', 'ynh.fr'] choices: ['nohost.me', 'noho.st', 'ynh.fr'],
} },
}, },
dynDomainPassword: { dynDomainPassword: {
@ -145,8 +174,8 @@ export default {
props: { props: {
id: 'dyn-dns-password', id: 'dyn-dns-password',
placeholder: '••••••••', placeholder: '••••••••',
type: 'password' type: 'password',
} },
}, },
dynDomainPasswordConfirmation: { dynDomainPasswordConfirmation: {
@ -154,8 +183,8 @@ export default {
props: { props: {
id: 'dyn-dns-password-confirmation', id: 'dyn-dns-password-confirmation',
placeholder: '••••••••', placeholder: '••••••••',
type: 'password' type: 'password',
} },
}, },
localDomain: { localDomain: {
@ -164,68 +193,70 @@ export default {
id: 'dyn-domain', id: 'dyn-domain',
placeholder: this.$i18n.t('placeholder.domain').split('.')[0], placeholder: this.$i18n.t('placeholder.domain').split('.')[0],
type: 'domain', type: 'domain',
choices: ['local', 'test'] choices: ['local', 'test'],
} },
} },
} },
} }
}, },
computed: { computed: {
...mapGetters(['domains']), ...mapGetters(['domains']),
dynDnsForbiden () { dynDnsForbiden() {
if (!this.domains) return false if (!this.domains) return false
const dynDomains = this.fields.dynDomain.props.choices const dynDomains = this.fields.dynDomain.props.choices
return this.domains.some(domain => { return this.domains.some((domain) => {
return dynDomains.some(dynDomain => domain.includes(dynDomain)) return dynDomains.some((dynDomain) => domain.includes(dynDomain))
}) })
}, },
domainIsVisible () { domainIsVisible() {
return this.selected === 'domain' return this.selected === 'domain'
}, },
dynDomainIsVisible () { dynDomainIsVisible() {
return this.selected === 'dynDomain' return this.selected === 'dynDomain'
}, },
localDomainIsVisible () { localDomainIsVisible() {
return this.selected === 'localDomain' return this.selected === 'localDomain'
} },
}, },
validations () { validations() {
return { return {
selected: { required }, selected: { required },
form: ['domain', 'localDomain'].includes(this.selected) form: ['domain', 'localDomain'].includes(this.selected)
? { ? {
[this.selected]: this.selected === 'domain' [this.selected]:
? { required, domain } this.selected === 'domain'
: { localPart: { required, dynDomain } } ? { required, domain }
} : { localPart: { required, dynDomain } },
}
: { : {
dynDomain: { localPart: { required, dynDomain } }, dynDomain: { localPart: { required, dynDomain } },
dynDomainPassword: { passwordLenght: minLength(8) }, dynDomainPassword: { passwordLenght: minLength(8) },
dynDomainPasswordConfirmation: { passwordMatch: sameAs('dynDomainPassword') } dynDomainPasswordConfirmation: {
} passwordMatch: sameAs('dynDomainPassword'),
},
},
} }
}, },
methods: { methods: {
async onSubmit () { async onSubmit() {
const domainType = this.selected const domainType = this.selected
const form = await formatFormData({ const form = await formatFormData({
domain: this.form[domainType], domain: this.form[domainType],
dyndns_recovery_password: domainType === 'dynDomain' dyndns_recovery_password:
? this.form.dynDomainPassword domainType === 'dynDomain' ? this.form.dynDomainPassword : '',
: ''
}) })
this.$emit('submit', form) this.$emit('submit', form)
} },
}, },
created () { created() {
if (this.dynDnsForbiden) { if (this.dynDnsForbiden) {
this.selected = 'domain' this.selected = 'domain'
} }
@ -234,7 +265,7 @@ export default {
mixins: [validationMixin], mixins: [validationMixin],
components: { components: {
AdressInputSelect AdressInputSelect,
} },
} }
</script> </script>

View file

@ -8,15 +8,17 @@
<div class="alert alert-info my-3"> <div class="alert alert-info my-3">
<span v-html="$t('api_error.help')" /> <span v-html="$t('api_error.help')" />
<br>{{ $t('api_error.info') }} <br />{{ $t('api_error.info') }}
</div> </div>
<!-- FIXME USE DD DL DT --> <!-- FIXME USE DD DL DT -->
<p class="m-0"> <p class="m-0">
<strong v-t="'error'" />: <code>"{{ error.code }}" {{ error.status }}</code> <strong v-t="'error'" />:
<code>"{{ error.code }}" {{ error.status }}</code>
</p> </p>
<p> <p>
<strong v-t="'action'" />: <code>"{{ error.method }}" {{ error.path }}</code> <strong v-t="'action'" />:
<code>"{{ error.method }}" {{ error.path }}</code>
</p> </p>
<p> <p>
@ -43,10 +45,7 @@
<BCardFooter footer-bg-variant="danger"> <BCardFooter footer-bg-variant="danger">
<!-- TODO add copy error ? --> <!-- TODO add copy error ? -->
<BButton <BButton variant="light" size="sm" v-t="'ok'" @click="dismiss" />
variant="light" size="sm"
v-t="'ok'" @click="dismiss"
/>
</BCardFooter> </BCardFooter>
</div> </div>
</template> </template>
@ -58,35 +57,36 @@ export default {
name: 'ErrorDisplay', name: 'ErrorDisplay',
components: { components: {
MessageListGroup MessageListGroup,
}, },
props: { props: {
request: { type: [Object, null], default: null } request: { type: [Object, null], default: null },
}, },
computed: { computed: {
error () { error() {
return this.request.error return this.request.error
}, },
messages () { messages() {
const messages = this.request.messages const messages = this.request.messages
if (messages && messages.length > 0) return messages if (messages && messages.length > 0) return messages
return null return null
} },
}, },
methods: { methods: {
dismiss () { dismiss() {
this.$store.dispatch('DISMISS_ERROR', this.request) this.$store.dispatch('DISMISS_ERROR', this.request)
} },
} },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
code, pre code { code,
pre code {
color: $black; color: $black;
} }
</style> </style>

View file

@ -2,22 +2,29 @@
<BCard no-body id="console"> <BCard no-body id="console">
<!-- HISTORY BAR --> <!-- HISTORY BAR -->
<BCardHeader <BCardHeader
role="button" tabindex="0" role="button"
:aria-expanded="open ? 'true' : 'false'" aria-controls="console-collapse" tabindex="0"
header-tag="header" :header-bg-variant="open ? 'best' : 'white'" :aria-expanded="open ? 'true' : 'false'"
aria-controls="console-collapse"
header-tag="header"
:header-bg-variant="open ? 'best' : 'white'"
:class="{ 'text-white': open }" :class="{ 'text-white': open }"
class="d-flex align-items-center" class="d-flex align-items-center"
@mousedown.left.prevent="onHistoryBarClick" @mousedown.left.prevent="onHistoryBarClick"
@keyup.space.enter.prevent="onHistoryBarKey" @keyup.space.enter.prevent="onHistoryBarKey"
> >
<h5 class="m-0"> <h5 class="m-0">
<YIcon iname="history" /> <span class="d-none d-sm-inline font-weight-bold">{{ $t('history.title') }}</span> <YIcon iname="history" />
<span class="d-none d-sm-inline font-weight-bold">
{{ $t('history.title') }}
</span>
</h5> </h5>
<!-- CURRENT/LAST ACTION --> <!-- CURRENT/LAST ACTION -->
<BButton <BButton
v-if="lastAction" v-if="lastAction"
size="sm" pill size="sm"
pill
class="ml-auto py-0" class="ml-auto py-0"
:variant="open ? 'light' : 'best'" :variant="open ? 'light' : 'best'"
@click.prevent="onLastActionClick" @click.prevent="onLastActionClick"
@ -25,36 +32,49 @@
> >
<small>{{ $t('history.last_action') }}</small> <small>{{ $t('history.last_action') }}</small>
</BButton> </BButton>
<QueryHeader v-if="lastAction" :request="lastAction" class="w-auto ml-2 xs-hide" /> <QueryHeader
v-if="lastAction"
:request="lastAction"
class="w-auto ml-2 xs-hide"
/>
</BCardHeader> </BCardHeader>
<BCollapse id="console-collapse" v-model="open"> <BCollapse id="console-collapse" v-model="open">
<div <div class="accordion" role="tablist" id="history" ref="history">
class="accordion" role="tablist"
id="history" ref="history"
>
<p v-if="history.length === 0" class="alert m-0 px-2 py-1"> <p v-if="history.length === 0" class="alert m-0 px-2 py-1">
{{ $t('history.is_empty') }} {{ $t('history.is_empty') }}
</p> </p>
<!-- ACTION LIST --> <!-- ACTION LIST -->
<BCard <BCard
v-for="(action, i) in history" :key="i" v-for="(action, i) in history"
no-body class="rounded-0 rounded-top border-left-0 border-right-0" :key="i"
no-body
class="rounded-0 rounded-top border-left-0 border-right-0"
> >
<!-- ACTION --> <!-- ACTION -->
<BCardHeader header-tag="header" header-bg-variant="white" class="sticky-top d-flex"> <BCardHeader
header-tag="header"
header-bg-variant="white"
class="sticky-top d-flex"
>
<!-- ACTION DESC --> <!-- ACTION DESC -->
<QueryHeader <QueryHeader
role="tab" v-b-toggle="action.messages.length ? 'messages-collapse-' + i : false" role="tab"
:request="action" show-time show-error v-b-toggle="
action.messages.length ? 'messages-collapse-' + i : false
"
:request="action"
show-time
show-error
/> />
</BCardHeader> </BCardHeader>
<!-- ACTION MESSAGES --> <!-- ACTION MESSAGES -->
<BCollapse <BCollapse
v-if="action.messages.length" v-if="action.messages.length"
:id="'messages-collapse-' + i" accordion="my-accordion" :id="'messages-collapse-' + i"
accordion="my-accordion"
role="tabpanel" role="tabpanel"
@shown="scrollToAction(i)" @shown="scrollToAction(i)"
@hide="scrollToAction(i)" @hide="scrollToAction(i)"
@ -78,33 +98,35 @@ export default {
components: { components: {
QueryHeader, QueryHeader,
MessageListGroup MessageListGroup,
}, },
props: { props: {
value: { type: Boolean, default: false }, value: { type: Boolean, default: false },
height: { type: [Number, String], default: 30 } height: { type: [Number, String], default: 30 },
}, },
data () { data() {
return { return {
open: false open: false,
} }
}, },
computed: { computed: {
...mapGetters(['history', 'lastAction', 'waiting', 'error']) ...mapGetters(['history', 'lastAction', 'waiting', 'error']),
}, },
methods: { methods: {
scrollToAction (actionIndex) { scrollToAction(actionIndex) {
const actionCard = this.$el.querySelector('#messages-collapse-' + actionIndex).parentElement const actionCard = this.$el.querySelector(
'#messages-collapse-' + actionIndex,
).parentElement
const headerOffset = actionCard.firstElementChild.offsetHeight const headerOffset = actionCard.firstElementChild.offsetHeight
// Can't use `scrollIntoView()` here since it will also scroll in the main content. // Can't use `scrollIntoView()` here since it will also scroll in the main content.
this.$refs.history.scrollTop = actionCard.offsetTop - headerOffset this.$refs.history.scrollTop = actionCard.offsetTop - headerOffset
}, },
async onLastActionClick () { async onLastActionClick() {
if (!this.open) { if (!this.open) {
this.open = true this.open = true
await this.$nextTick() await this.$nextTick()
@ -122,15 +144,23 @@ export default {
} }
}, },
onHistoryBarKey (e) { onHistoryBarKey(e) {
// FIXME interactive element in another is not valid, need to find another way. // FIXME interactive element in another is not valid, need to find another way.
if (e.target.nodeName === 'BUTTON' || e.target.parentElement.nodeName === 'BUTTON') return if (
e.target.nodeName === 'BUTTON' ||
e.target.parentElement.nodeName === 'BUTTON'
)
return
this.open = !this.open this.open = !this.open
}, },
onHistoryBarClick (e) { onHistoryBarClick(e) {
// FIXME interactive element in another is not valid, need to find another way. // FIXME interactive element in another is not valid, need to find another way.
if (e.target.nodeName === 'BUTTON' || e.target.parentElement.nodeName === 'BUTTON') return if (
e.target.nodeName === 'BUTTON' ||
e.target.parentElement.nodeName === 'BUTTON'
)
return
const historyElem = this.$refs.history const historyElem = this.$refs.history
let mousePos = e.clientY let mousePos = e.clientY
@ -180,8 +210,8 @@ export default {
} }
window.addEventListener('mouseup', onMouseUp) window.addEventListener('mouseup', onMouseUp)
} },
} },
} }
</script> </script>
@ -207,7 +237,6 @@ export default {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
font-size: $font-size-sm; font-size: $font-size-sm;
& > header { & > header {
cursor: ns-resize; cursor: ns-resize;
} }

View file

@ -20,7 +20,9 @@
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<BButton <BButton
variant="success" v-t="'retry'" class="ml-auto" variant="success"
v-t="'retry'"
class="ml-auto"
@click="tryToReconnect()" @click="tryToReconnect()"
/> />
</div> </div>
@ -40,39 +42,41 @@ import { mapGetters } from 'vuex'
import api from '@/api' import api from '@/api'
import LoginView from '@/views/LoginView.vue' import LoginView from '@/views/LoginView.vue'
export default { export default {
name: 'ReconnectingDisplay', name: 'ReconnectingDisplay',
components: { components: {
LoginView LoginView,
}, },
data () { data() {
return { return {
status: 'reconnecting', status: 'reconnecting',
origin: undefined origin: undefined,
} }
}, },
computed: { computed: {
...mapGetters(['reconnecting']) ...mapGetters(['reconnecting']),
}, },
methods: { methods: {
tryToReconnect (initialDelay = 0) { tryToReconnect(initialDelay = 0) {
this.status = 'reconnecting' this.status = 'reconnecting'
api.tryToReconnect({ ...this.reconnecting, initialDelay }).then(() => { api
this.status = 'success' .tryToReconnect({ ...this.reconnecting, initialDelay })
}).catch(() => { .then(() => {
this.status = 'failed' this.status = 'success'
}) })
} .catch(() => {
this.status = 'failed'
})
},
}, },
created () { created() {
this.origin = this.reconnecting.origin || 'unknown' this.origin = this.reconnecting.origin || 'unknown'
this.tryToReconnect(this.reconnecting.initialDelay) this.tryToReconnect(this.reconnecting.initialDelay)
} },
} }
</script> </script>

View file

@ -1,6 +1,7 @@
<template> <template>
<BOverlay <BOverlay
variant="white" opacity="0.75" variant="white"
opacity="0.75"
no-center no-center
:show="waiting || reconnecting || error !== null" :show="waiting || reconnecting || error !== null"
> >
@ -20,7 +21,12 @@
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { ErrorDisplay, WarningDisplay, WaitingDisplay, ReconnectingDisplay } from '@/views/_partials' import {
ErrorDisplay,
WarningDisplay,
WaitingDisplay,
ReconnectingDisplay,
} from '@/views/_partials'
import QueryHeader from '@/components/QueryHeader.vue' import QueryHeader from '@/components/QueryHeader.vue'
export default { export default {
@ -31,13 +37,13 @@ export default {
WarningDisplay, WarningDisplay,
WaitingDisplay, WaitingDisplay,
ReconnectingDisplay, ReconnectingDisplay,
QueryHeader QueryHeader,
}, },
computed: { computed: {
...mapGetters(['waiting', 'reconnecting', 'error', 'currentRequest']), ...mapGetters(['waiting', 'reconnecting', 'error', 'currentRequest']),
component () { component() {
const { error, reconnecting, currentRequest: request } = this const { error, reconnecting, currentRequest: request } = this
if (error) { if (error) {
@ -49,8 +55,8 @@ export default {
} else { } else {
return { name: 'WaitingDisplay', request } return { name: 'WaitingDisplay', request }
} }
} },
} },
} }
</script> </script>
@ -81,14 +87,14 @@ export default {
} }
.card-footer { .card-footer {
padding: .5rem .75rem; padding: 0.5rem 0.75rem;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
} }
.card-header { .card-header {
padding: .5rem .75rem; padding: 0.5rem 0.75rem;
} }
} }
</style> </style>

View file

@ -1,13 +1,13 @@
<template> <template>
<!-- This card receives style from `ViewLockOverlay` if used inside it --> <!-- This card receives style from `ViewLockOverlay` if used inside it -->
<BCardBody> <BCardBody>
<BCardTitle class="text-center mt-4" v-t="hasMessages ? 'api.processing' : 'api_waiting'" /> <BCardTitle
class="text-center mt-4"
v-t="hasMessages ? 'api.processing' : 'api_waiting'"
/>
<!-- PROGRESS BAR --> <!-- PROGRESS BAR -->
<BProgress <BProgress v-if="progress" class="my-4" :max="progress.max" height=".5rem">
v-if="progress" class="my-4"
:max="progress.max" height=".5rem"
>
<BProgressBar variant="success" :value="progress.values[0]" /> <BProgressBar variant="success" :value="progress.values[0]" />
<BProgressBar variant="warning" :value="progress.values[1]" animated /> <BProgressBar variant="warning" :value="progress.values[1]" animated />
<BProgressBar variant="secondary" :value="progress.values[2]" striped /> <BProgressBar variant="secondary" :value="progress.values[2]" striped />
@ -16,8 +16,11 @@
<YSpinner v-else class="my-4" /> <YSpinner v-else class="my-4" />
<MessageListGroup <MessageListGroup
v-if="hasMessages" :messages="request.messages" v-if="hasMessages"
bordered fixed-height auto-scroll :messages="request.messages"
bordered
fixed-height
auto-scroll
:limit="100" :limit="100"
/> />
</BCardBody> </BCardBody>
@ -30,26 +33,26 @@ export default {
name: 'WaitingDisplay', name: 'WaitingDisplay',
components: { components: {
MessageListGroup MessageListGroup,
}, },
props: { props: {
request: { type: Object, required: true } request: { type: Object, required: true },
}, },
computed: { computed: {
hasMessages () { hasMessages() {
return this.request.messages && this.request.messages.length > 0 return this.request.messages && this.request.messages.length > 0
}, },
progress () { progress() {
const progress = this.request.progress const progress = this.request.progress
if (!progress) return null if (!progress) return null
return { return {
values: progress, values: progress,
max: progress.reduce((sum, value) => (sum + value), 0) max: progress.reduce((sum, value) => sum + value, 0),
} }
} },
} },
} }
</script> </script>

View file

@ -6,10 +6,7 @@
</BCardBody> </BCardBody>
<BCardFooter footer-bg-variant="warning"> <BCardFooter footer-bg-variant="warning">
<BButton <BButton variant="light" size="sm" v-t="'ok'" @click="dismiss" />
variant="light" size="sm"
v-t="'ok'" @click="dismiss"
/>
</BCardFooter> </BCardFooter>
</div> </div>
</template> </template>
@ -19,27 +16,27 @@ export default {
name: 'WarningDisplay', name: 'WarningDisplay',
props: { props: {
request: { type: Object, required: true } request: { type: Object, required: true },
}, },
computed: { computed: {
warning () { warning() {
const messages = this.request.messages const messages = this.request.messages
return messages[messages.length - 1] return messages[messages.length - 1]
} },
}, },
methods: { methods: {
dismiss () { dismiss() {
this.$store.dispatch('DISMISS_WARNING', this.request) this.$store.dispatch('DISMISS_WARNING', this.request)
} },
} },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.card-body { .card-body {
padding-bottom: 1.5rem !important; padding-bottom: 1.5rem !important;
margin-bottom: 0; margin-bottom: 0;
} }
</style> </style>

View file

@ -1,7 +1,10 @@
<template> <template>
<ViewSearch <ViewSearch
:items="apps" :filtered-items="filteredApps" items-name="apps" :items="apps"
:queries="queries" @queries-response="onQueriesResponse" :filtered-items="filteredApps"
items-name="apps"
:queries="queries"
@queries-response="onQueriesResponse"
> >
<template #top-bar> <template #top-bar>
<div id="view-top-bar"> <div id="view-top-bar">
@ -11,11 +14,17 @@
<YIcon iname="search" /> <YIcon iname="search" />
</BInputGroupPrepend> </BInputGroupPrepend>
<BFormInput <BFormInput
id="search-input" :placeholder="$t('search.for', { items: $tc('items.apps', 2) })" id="search-input"
:value="search" @input="updateQuery('search', $event)" :placeholder="$t('search.for', { items: $tc('items.apps', 2) })"
:value="search"
@input="updateQuery('search', $event)"
/> />
<BInputGroupAppend> <BInputGroupAppend>
<BFormSelect :value="quality" :options="qualityOptions" @change="updateQuery('quality', $event)" /> <BFormSelect
:value="quality"
:options="qualityOptions"
@change="updateQuery('quality', $event)"
/>
</BInputGroupAppend> </BInputGroupAppend>
</BInputGroup> </BInputGroup>
@ -24,9 +33,17 @@
<BInputGroupPrepend is-text> <BInputGroupPrepend is-text>
<YIcon iname="filter" /> <YIcon iname="filter" />
</BInputGroupPrepend> </BInputGroupPrepend>
<BFormSelect :value="category" :options="categories" @change="updateQuery('category', $event)" /> <BFormSelect
:value="category"
:options="categories"
@change="updateQuery('category', $event)"
/>
<BInputGroupAppend> <BInputGroupAppend>
<BButton variant="primary" :disabled="category === null" @click="updateQuery('category', null)"> <BButton
variant="primary"
:disabled="category === null"
@click="updateQuery('category', null)"
>
{{ $t('app_show_categories') }} {{ $t('app_show_categories') }}
</BButton> </BButton>
</BInputGroupAppend> </BInputGroupAppend>
@ -34,16 +51,20 @@
<!-- CATEGORIES SUBTAGS --> <!-- CATEGORIES SUBTAGS -->
<BInputGroup v-if="subtags" class="mt-3 subtags"> <BInputGroup v-if="subtags" class="mt-3 subtags">
<BInputGroupPrepend is-text> <BInputGroupPrepend is-text> Subtags </BInputGroupPrepend>
Subtags
</BInputGroupPrepend>
<BFormRadioGroup <BFormRadioGroup
id="subtags-radio" name="subtags" id="subtags-radio"
:checked="subtag" :options="subtags" @change="updateQuery('subtag', $event)" name="subtags"
buttons button-variant="outline-secondary" :checked="subtag"
:options="subtags"
@change="updateQuery('subtag', $event)"
buttons
button-variant="outline-secondary"
/> />
<BFormSelect <BFormSelect
id="subtags-select" :value="subtag" :options="subtags" id="subtags-select"
:value="subtag"
:options="subtags"
@change="updateQuery('subtag', $event)" @change="updateQuery('subtag', $event)"
/> />
</BInputGroup> </BInputGroup>
@ -53,8 +74,10 @@
<!-- CATEGORIES CARDS --> <!-- CATEGORIES CARDS -->
<BCardGroup v-if="category === null" deck tag="ul"> <BCardGroup v-if="category === null" deck tag="ul">
<BCard <BCard
v-for="cat in categories.slice(1)" :key="cat.value" v-for="cat in categories.slice(1)"
tag="li" class="category-card" :key="cat.value"
tag="li"
class="category-card"
> >
<BCardTitle> <BCardTitle>
<BLink @click="updateQuery('category', cat.value)" class="card-link"> <BLink @click="updateQuery('category', cat.value)" class="card-link">
@ -68,33 +91,55 @@
<!-- APPS CARDS --> <!-- APPS CARDS -->
<CardDeckFeed v-else> <CardDeckFeed v-else>
<BCard <BCard
v-for="(app, i) in filteredApps" :key="app.id" v-for="(app, i) in filteredApps"
tag="article" :aria-labelledby="`${app.id}-title`" :aria-describedby="`${app.id}-desc`" :key="app.id"
tabindex="0" :aria-posinset="i + 1" :aria-setsize="filteredApps.length" tag="article"
no-body class="app-card" :aria-labelledby="`${app.id}-title`"
:aria-describedby="`${app.id}-desc`"
tabindex="0"
:aria-posinset="i + 1"
:aria-setsize="filteredApps.length"
no-body
class="app-card"
> >
<BCardBody class="d-flex"> <BCardBody class="d-flex">
<BImg v-if="app.logo_hash" class="app-logo rounded" :src="`./applogos/${app.logo_hash}.png`" /> <BImg
v-if="app.logo_hash"
class="app-logo rounded"
:src="`./applogos/${app.logo_hash}.png`"
/>
<div> <div>
<BCardTitle :id="`${app.id}-title`" class="d-flex mb-2"> <BCardTitle :id="`${app.id}-title`" class="d-flex mb-2">
<BLink :to="{ name: 'app-install', params: { id: app.id }}" class="card-link"> <BLink
:to="{ name: 'app-install', params: { id: app.id } }"
class="card-link"
>
{{ app.manifest.name }} {{ app.manifest.name }}
</BLink> </BLink>
<small v-if="app.state !== 'working' || app.high_quality" class="d-flex align-items-center ml-2 position-relative"> <small
v-if="app.state !== 'working' || app.high_quality"
class="d-flex align-items-center ml-2 position-relative"
>
<BBadge <BBadge
v-if="app.state !== 'working'" v-if="app.state !== 'working'"
:variant="app.color" :variant="app.color"
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)" v-b-popover.hover.bottom="
$t(`app_state_${app.state}_explanation`)
"
> >
<!-- app.state can be 'lowquality' or 'inprogress' --> <!-- app.state can be 'lowquality' or 'inprogress' -->
{{ $t('app_state_' + app.state) }} {{ $t('app_state_' + app.state) }}
</BBadge> </BBadge>
<YIcon <YIcon
v-if="app.high_quality" iname="star" class="star" v-if="app.high_quality"
v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)" iname="star"
class="star"
v-b-popover.hover.bottom="
$t(`app_state_highquality_explanation`)
"
/> />
</small> </small>
</BCardTitle> </BCardTitle>
@ -103,8 +148,14 @@
{{ app.manifest.description }} {{ app.manifest.description }}
</BCardText> </BCardText>
<BCardText v-if="!app.maintained" class="align-self-end position-relative mt-auto"> <BCardText
<span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')"> v-if="!app.maintained"
class="align-self-end position-relative mt-auto"
>
<span
class="alert-warning p-1"
v-b-popover.hover.top="$t('orphaned_details')"
>
<YIcon iname="warning" /> {{ $t('orphaned') }} <YIcon iname="warning" /> {{ $t('orphaned') }}
</span> </span>
</BCardText> </BCardText>
@ -125,37 +176,52 @@
<template #bot> <template #bot>
<!-- INSTALL CUSTOM APP --> <!-- INSTALL CUSTOM APP -->
<CardForm <CardForm
:title="$t('custom_app_install')" icon="download" :title="$t('custom_app_install')"
@submit.prevent="onCustomInstallClick" :submit-text="$t('install')" icon="download"
:validation="$v" class="mt-5" @submit.prevent="onCustomInstallClick"
:submit-text="$t('install')"
:validation="$v"
class="mt-5"
> >
<template #disclaimer> <template #disclaimer>
<div class="alert alert-warning"> <div class="alert alert-warning">
<YIcon iname="exclamation-triangle" /> {{ $t('confirm_install_custom_app') }} <YIcon iname="exclamation-triangle" />
{{ $t('confirm_install_custom_app') }}
</div> </div>
</template> </template>
<!-- URL --> <!-- URL -->
<FormField v-bind="customInstall.field" v-model="customInstall.url" :validation="$v.customInstall.url" /> <FormField
v-bind="customInstall.field"
v-model="customInstall.url"
:validation="$v.customInstall.url"
/>
</CardForm> </CardForm>
</template> </template>
<!-- CUSTOM SKELETON --> <!-- CUSTOM SKELETON -->
<template #skeleton> <template #skeleton>
<BCardGroup deck> <BCardGroup deck>
<BCard <BCard v-for="i in 15" :key="i" no-body style="min-height: 10rem">
v-for="i in 15" :key="i"
no-body style="min-height: 10rem;"
>
<div class="d-flex w-100 mt-auto"> <div class="d-flex w-100 mt-auto">
<BSkeleton width="30px" height="30px" class="mr-2 ml-auto" /> <BSkeleton width="30px" height="30px" class="mr-2 ml-auto" />
<BSkeleton :width="randint(30, 70) + '%'" height="30px" class="mr-auto" /> <BSkeleton
:width="randint(30, 70) + '%'"
height="30px"
class="mr-auto"
/>
</div> </div>
<BSkeleton <BSkeleton
v-if="randint(0, 1)" v-if="randint(0, 1)"
:width="randint(30, 85) + '%'" height="24px" class="mx-auto" :width="randint(30, 85) + '%'"
height="24px"
class="mx-auto"
/>
<BSkeleton
:width="randint(30, 85) + '%'"
height="24px"
class="mx-auto mb-auto"
/> />
<BSkeleton :width="randint(30, 85) + '%'" height="24px" class="mx-auto mb-auto" />
</BCard> </BCard>
</BCardGroup> </BCardGroup>
</template> </template>
@ -173,21 +239,19 @@ export default {
name: 'AppCatalog', name: 'AppCatalog',
components: { components: {
CardDeckFeed CardDeckFeed,
}, },
props: { props: {
search: { type: String, default: '' }, search: { type: String, default: '' },
quality: { type: String, default: 'decent_quality' }, quality: { type: String, default: 'decent_quality' },
category: { type: String, default: null }, category: { type: String, default: null },
subtag: { type: String, default: 'all' } subtag: { type: String, default: 'all' },
}, },
data () { data() {
return { return {
queries: [ queries: [['GET', 'apps/catalog?full&with_categories&with_antifeatures']],
['GET', 'apps/catalog?full&with_categories&with_antifeatures']
],
// Data // Data
apps: undefined, apps: undefined,
@ -197,13 +261,16 @@ export default {
// Filtering options // Filtering options
qualityOptions: [ qualityOptions: [
{ value: 'high_quality', text: this.$i18n.t('only_highquality_apps') }, { value: 'high_quality', text: this.$i18n.t('only_highquality_apps') },
{ value: 'decent_quality', text: this.$i18n.t('only_decent_quality_apps') }, {
value: 'decent_quality',
text: this.$i18n.t('only_decent_quality_apps'),
},
{ value: 'working', text: this.$i18n.t('only_working_apps') }, { value: 'working', text: this.$i18n.t('only_working_apps') },
{ value: 'all', text: this.$i18n.t('all_apps') } { value: 'all', text: this.$i18n.t('all_apps') },
], ],
categories: [ categories: [
{ text: this.$i18n.t('app_choose_category'), value: null }, { text: this.$i18n.t('app_choose_category'), value: null },
{ text: this.$i18n.t('all_apps'), value: 'all', icon: 'search' } { text: this.$i18n.t('all_apps'), value: 'all', icon: 'search' },
// The rest is filled from api data // The rest is filled from api data
], ],
@ -213,31 +280,33 @@ export default {
label: this.$i18n.t('url'), label: this.$i18n.t('url'),
props: { props: {
id: 'custom-install', id: 'custom-install',
placeholder: 'https://some.git.forge.tld/USER/REPOSITORY' placeholder: 'https://some.git.forge.tld/USER/REPOSITORY',
} },
}, },
url: '' url: '',
} },
} }
}, },
computed: { computed: {
filteredApps () { filteredApps() {
if (!this.apps || this.category === null) return if (!this.apps || this.category === null) return
const search = this.search.toLowerCase() const search = this.search.toLowerCase()
if (this.quality === 'all' && this.category === 'all' && search === '') { if (this.quality === 'all' && this.category === 'all' && search === '') {
return this.apps return this.apps
} }
const filtered = this.apps.filter(app => { const filtered = this.apps.filter((app) => {
// app doesn't match quality filter // app doesn't match quality filter
if (this.quality !== 'all' && !app[this.quality]) return false if (this.quality !== 'all' && !app[this.quality]) return false
// app doesn't match category filter // app doesn't match category filter
if (this.category !== 'all' && app.category !== this.category) return false if (this.category !== 'all' && app.category !== this.category)
return false
if (this.subtag !== 'all') { if (this.subtag !== 'all') {
const appMatchSubtag = this.subtag === 'others' const appMatchSubtag =
? app.subtags.length === 0 this.subtag === 'others'
: app.subtags.includes(this.subtag) ? app.subtags.length === 0
: app.subtags.includes(this.subtag)
// app doesn't match subtag filter // app doesn't match subtag filter
if (!appMatchSubtag) return false if (!appMatchSubtag) return false
} }
@ -248,13 +317,15 @@ export default {
return filtered.length ? filtered : null return filtered.length ? filtered : null
}, },
subtags () { subtags() {
// build an options array for subtags v-model/options // build an options array for subtags v-model/options
if (this.category && this.categories.length > 2) { if (this.category && this.categories.length > 2) {
const category = this.categories.find(cat => cat.value === this.category) const category = this.categories.find(
(cat) => cat.value === this.category,
)
if (category.subtags) { if (category.subtags) {
const subtags = [{ text: this.$i18n.t('all'), value: 'all' }] const subtags = [{ text: this.$i18n.t('all'), value: 'all' }]
category.subtags.forEach(subtag => { category.subtags.forEach((subtag) => {
subtags.push({ text: subtag.title, value: subtag.id }) subtags.push({ text: subtag.title, value: subtag.id })
}) })
subtags.push({ text: this.$i18n.t('others'), value: 'others' }) subtags.push({ text: this.$i18n.t('others'), value: 'others' })
@ -262,21 +333,22 @@ export default {
} }
} }
return null return null
} },
}, },
validations: { validations: {
customInstall: { customInstall: {
url: { required, appRepoUrl } url: { required, appRepoUrl },
} },
}, },
methods: { methods: {
onQueriesResponse (data) { onQueriesResponse(data) {
const apps = [] const apps = []
for (const key in data.apps) { for (const key in data.apps) {
const app = data.apps[key] const app = data.apps[key]
app.isInstallable = !app.installed || app.manifest.integration.multi_instance app.isInstallable =
!app.installed || app.manifest.integration.multi_instance
app.working = app.state === 'working' app.working = app.state === 'working'
app.decent_quality = app.working && app.level > 4 app.decent_quality = app.working && app.level > 4
app.high_quality = app.working && app.level >= 8 app.high_quality = app.working && app.level >= 8
@ -295,57 +367,71 @@ export default {
app.state, app.state,
app.manifest.name, app.manifest.name,
app.manifest.description, app.manifest.description,
app.potential_alternative_to.join(' ') app.potential_alternative_to.join(' '),
].join(' ').toLowerCase() ]
.join(' ')
.toLowerCase()
apps.push(app) apps.push(app)
} }
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1) this.apps = apps.sort((a, b) => (a.id > b.id ? 1 : -1))
// CATEGORIES // CATEGORIES
data.categories.forEach(({ title, id, icon, subtags, description }) => { data.categories.forEach(({ title, id, icon, subtags, description }) => {
this.categories.push({ text: title, value: id, icon, subtags, description }) this.categories.push({
text: title,
value: id,
icon,
subtags,
description,
})
}) })
this.antifeatures = Object.fromEntries(data.antifeatures.map((af) => ([af.id, af]))) this.antifeatures = Object.fromEntries(
data.antifeatures.map((af) => [af.id, af]),
)
}, },
updateQuery (key, value) { updateQuery(key, value) {
// Update the query string without reloading the page // Update the query string without reloading the page
this.$router.replace({ this.$router.replace({
query: { query: {
...this.$route.query, ...this.$route.query,
// allow search without selecting a category // allow search without selecting a category
category: this.$route.query.category || 'all', category: this.$route.query.category || 'all',
[key]: value [key]: value,
} },
}) })
}, },
// INSTALL APP // INSTALL APP
async onInstallClick (appId) { async onInstallClick(appId) {
const app = this.apps.find((app) => app.id === appId) const app = this.apps.find((app) => app.id === appId)
if (!app.decent_quality) { if (!app.decent_quality) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + app.state)) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_install_app_' + app.state),
)
if (!confirmed) return if (!confirmed) return
} }
this.$router.push({ name: 'app-install', params: { id: app.id } }) this.$router.push({ name: 'app-install', params: { id: app.id } })
}, },
// INSTALL CUSTOM APP // INSTALL CUSTOM APP
async onCustomInstallClick () { async onCustomInstallClick() {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_custom_app')) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_install_custom_app'),
)
if (!confirmed) return if (!confirmed) return
const url = this.customInstall.url const url = this.customInstall.url
this.$router.push({ this.$router.push({
name: 'app-install-custom', name: 'app-install-custom',
params: { id: url.endsWith('/') ? url : url + '/' } params: { id: url.endsWith('/') ? url : url + '/' },
}) })
}, },
randint randint,
}, },
mixins: [validationMixin] mixins: [validationMixin],
} }
</script> </script>
@ -364,7 +450,7 @@ export default {
.subtags { .subtags {
#subtags-radio { #subtags-radio {
display: none display: none;
} }
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
#subtags-radio { #subtags-radio {
@ -418,7 +504,7 @@ export default {
// not maintained info // not maintained info
.alert-warning { .alert-warning {
font-size: .75em; font-size: 0.75em;
} }
.star { .star {
@ -450,7 +536,7 @@ export default {
} }
&:focus::after { &:focus::after {
box-shadow: 0 0 0 $btn-focus-width rgba($dark, .5); box-shadow: 0 0 0 $btn-focus-width rgba($dark, 0.5);
} }
} }
} }

View file

@ -1,9 +1,20 @@
<template> <template>
<ViewBase <ViewBase
:queries="queries" @queries-response="onQueriesResponse" :loading="loading" :queries="queries"
@queries-response="onQueriesResponse"
:loading="loading"
ref="view" ref="view"
> >
<YAlert v-if="app && app.doc && app.doc.notifications && app.doc.notifications.postInstall.length" variant="info" class="my-4"> <YAlert
v-if="
app &&
app.doc &&
app.doc.notifications &&
app.doc.notifications.postInstall.length
"
variant="info"
class="my-4"
>
<div class="d-md-flex align-items-center mb-3"> <div class="d-md-flex align-items-center mb-3">
<h2 v-t="'app.doc.notifications.post_install'" class="md-m-0" /> <h2 v-t="'app.doc.notifications.post_install'" class="md-m-0" />
<BButton <BButton
@ -18,12 +29,24 @@
</div> </div>
<VueShowdown <VueShowdown
v-for="[name, notif] in app.doc.notifications.postInstall" :key="name" v-for="[name, notif] in app.doc.notifications.postInstall"
:markdown="notif" flavor="github" :options="{ headerLevelStart: 4 }" :key="name"
:markdown="notif"
flavor="github"
:options="{ headerLevelStart: 4 }"
/> />
</YAlert> </YAlert>
<YAlert v-if="app && app.doc && app.doc.notifications && app.doc.notifications.postUpgrade.length" variant="info" class="my-4"> <YAlert
v-if="
app &&
app.doc &&
app.doc.notifications &&
app.doc.notifications.postUpgrade.length
"
variant="info"
class="my-4"
>
<div class="d-md-flex align-items-center mb-3"> <div class="d-md-flex align-items-center mb-3">
<h2 v-t="'app.doc.notifications.post_upgrade'" class="md-m-0" /> <h2 v-t="'app.doc.notifications.post_upgrade'" class="md-m-0" />
<BButton <BButton
@ -38,8 +61,11 @@
</div> </div>
<VueShowdown <VueShowdown
v-for="[name, notif] in app.doc.notifications.postUpgrade" :key="name" v-for="[name, notif] in app.doc.notifications.postUpgrade"
:markdown="notif" flavor="github" :options="{ headerLevelStart: 4 }" :key="name"
:markdown="notif"
flavor="github"
:options="{ headerLevelStart: 4 }"
/> />
</YAlert> </YAlert>
@ -56,8 +82,10 @@
<BButton <BButton
v-if="app.url" v-if="app.url"
:href="app.url" target="_blank" :href="app.url"
variant="success" class="ml-auto mr-2" target="_blank"
variant="success"
class="ml-auto mr-2"
> >
<YIcon iname="external-link" /> <YIcon iname="external-link" />
{{ $t('app.open_this_app') }} {{ $t('app.open_this_app') }}
@ -75,10 +103,11 @@
</div> </div>
<p class="text-secondary"> <p class="text-secondary">
<strong v-t="'app.installed_version'" /> {{ app.version }}<br> <strong v-t="'app.installed_version'" /> {{ app.version }}<br />
<template v-if="app.alternativeTo"> <template v-if="app.alternativeTo">
<strong v-t="'app.potential_alternative_to'" /> {{ app.alternativeTo }} <strong v-t="'app.potential_alternative_to'" />
{{ app.alternativeTo }}
</template> </template>
</p> </p>
@ -92,10 +121,7 @@
<VueShowdown :markdown="app.description" flavor="github" /> <VueShowdown :markdown="app.description" flavor="github" />
</section> </section>
<YAlert <YAlert v-if="config_panel_err" class="mb-4" variant="danger" icon="bug">
v-if="config_panel_err"
class="mb-4" variant="danger" icon="bug"
>
<p>{{ $t('app.info.config_panel_error') }}</p> <p>{{ $t('app.info.config_panel_error') }}</p>
<p>{{ config_panel_err }}</p> <p>{{ config_panel_err }}</p>
<p>{{ $t('app.info.config_panel_error_please_report') }}</p> <p>{{ $t('app.info.config_panel_error_please_report') }}</p>
@ -106,25 +132,38 @@
<!-- OPERATIONS TAB --> <!-- OPERATIONS TAB -->
<template v-if="currentTab === 'operations'" #tab-top> <template v-if="currentTab === 'operations'" #tab-top>
<!-- CHANGE PERMISSIONS LABEL --> <!-- CHANGE PERMISSIONS LABEL -->
<BFormGroup :label="$t('app_manage_label_and_tiles')" label-class="font-weight-bold"> <BFormGroup
:label="$t('app_manage_label_and_tiles')"
label-class="font-weight-bold"
>
<FormField <FormField
v-for="(perm, i) in app.permissions" :key="i" v-for="(perm, i) in app.permissions"
:label="perm.title" :label-for="'perm-' + i" :key="i"
label-cols="0" label-class="" class="m-0" :label="perm.title"
:validation="$v.form.labels.$each[i] " :label-for="'perm-' + i"
label-cols="0"
label-class=""
class="m-0"
:validation="$v.form.labels.$each[i]"
> >
<template #default="{ self }"> <template #default="{ self }">
<BInputGroup> <BInputGroup>
<InputItem <InputItem
:state="self.state" v-model="form.labels[i].label" :state="self.state"
:id="'perm' + i" :aria-describedby="'perm-' + i + '_group__BV_description_'" v-model="form.labels[i].label"
:id="'perm' + i"
:aria-describedby="'perm-' + i + '_group__BV_description_'"
/> />
<BInputGroupAppend v-if="perm.tileAvailable" is-text> <BInputGroupAppend v-if="perm.tileAvailable" is-text>
<CheckboxItem v-model="form.labels[i].show_tile" :label="$t('permission_show_tile_enabled')" /> <CheckboxItem
v-model="form.labels[i].show_tile"
:label="$t('permission_show_tile_enabled')"
/>
</BInputGroupAppend> </BInputGroupAppend>
<BInputGroupAppend> <BInputGroupAppend>
<BButton <BButton
variant="info" v-t="'save'" variant="info"
v-t="'save'"
@click="changeLabel(perm.name, form.labels[i])" @click="changeLabel(perm.name, form.labels[i])"
/> />
</BInputGroupAppend> </BInputGroupAppend>
@ -139,43 +178,52 @@
</template> </template>
</FormField> </FormField>
</BFormGroup> </BFormGroup>
<hr> <hr />
<!-- PERMISSIONS --> <!-- PERMISSIONS -->
<BFormGroup <BFormGroup
:label="$t('app_info_access_desc')" label-for="permissions" :label="$t('app_info_access_desc')"
label-class="font-weight-bold" label-cols-lg="0" label-for="permissions"
label-class="font-weight-bold"
label-cols-lg="0"
> >
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') : $t('nobody') }} {{
allowedGroups.length > 0 ? allowedGroups.join(', ') : $t('nobody')
}}
<BButton <BButton
size="sm" :to="{ name: 'group-list'}" variant="info" size="sm"
:to="{ name: 'group-list' }"
variant="info"
class="ml-2" class="ml-2"
> >
<YIcon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }} <YIcon iname="key-modern" />
{{ $t('groups_and_permissions_manage') }}
</BButton> </BButton>
</BFormGroup> </BFormGroup>
<hr> <hr />
<!-- CHANGE URL --> <!-- CHANGE URL -->
<BFormGroup <BFormGroup
:label="$t('app_info_changeurl_desc')" label-for="input-url" :label="$t('app_info_changeurl_desc')"
:label-cols-lg="app.supports_change_url ? 0 : 0" label-class="font-weight-bold" label-for="input-url"
:label-cols-lg="app.supports_change_url ? 0 : 0"
label-class="font-weight-bold"
v-if="app.is_webapp" v-if="app.is_webapp"
> >
<BInputGroup v-if="app.supports_change_url"> <BInputGroup v-if="app.supports_change_url">
<BInputGroupPrepend is-text> <BInputGroupPrepend is-text> https:// </BInputGroupPrepend>
https://
</BInputGroupPrepend>
<BInputGroupPrepend class="flex-grow-1"> <BInputGroupPrepend class="flex-grow-1">
<BFormSelect v-model="form.url.domain" :options="domains" /> <BFormSelect v-model="form.url.domain" :options="domains" />
</BInputGroupPrepend> </BInputGroupPrepend>
<BInputGroupPrepend is-text> <BInputGroupPrepend is-text> / </BInputGroupPrepend>
/
</BInputGroupPrepend>
<BFormInput id="input-url" v-model="form.url.path" class="flex-grow-3" /> <BFormInput
id="input-url"
v-model="form.url.path"
class="flex-grow-3"
/>
<BInputGroupAppend> <BInputGroupAppend>
<BButton @click="changeUrl" variant="info" v-t="'save'" /> <BButton @click="changeUrl" variant="info" v-t="'save'" />
@ -183,25 +231,36 @@
</BInputGroup> </BInputGroup>
<div v-else class="alert alert-warning"> <div v-else class="alert alert-warning">
<YIcon iname="exclamation" /> {{ $t('app_info_change_url_disabled_tooltip') }} <YIcon iname="exclamation" />
{{ $t('app_info_change_url_disabled_tooltip') }}
</div> </div>
</BFormGroup> </BFormGroup>
<hr v-if="app.is_webapp"> <hr v-if="app.is_webapp" />
<!-- MAKE DEFAULT --> <!-- MAKE DEFAULT -->
<BFormGroup <BFormGroup
:label="$t('app_info_default_desc', { domain: app.domain })" label-for="main-domain" :label="$t('app_info_default_desc', { domain: app.domain })"
label-class="font-weight-bold" label-cols-md="4" label-for="main-domain"
label-class="font-weight-bold"
label-cols-md="4"
v-if="app.is_webapp" v-if="app.is_webapp"
> >
<template v-if="!app.is_default"> <template v-if="!app.is_default">
<BButton @click="setAsDefaultDomain(false)" id="main-domain" variant="success"> <BButton
@click="setAsDefaultDomain(false)"
id="main-domain"
variant="success"
>
<YIcon iname="star" /> {{ $t('app_make_default') }} <YIcon iname="star" /> {{ $t('app_make_default') }}
</BButton> </BButton>
</template> </template>
<template v-else> <template v-else>
<BButton @click="setAsDefaultDomain(true)" id="main-domain" variant="warning"> <BButton
@click="setAsDefaultDomain(true)"
id="main-domain"
variant="warning"
>
<YIcon iname="star" /> {{ $t('app_make_not_default') }} <YIcon iname="star" /> {{ $t('app_make_not_default') }}
</BButton> </BButton>
</template> </template>
@ -211,12 +270,10 @@
<BCard v-if="app && app.doc.admin.length" no-body> <BCard v-if="app && app.doc.admin.length" no-body>
<BTabs card fill pills> <BTabs card fill pills>
<BTab <BTab v-for="[name, content] in app.doc.admin" :key="name">
v-for="[name, content] in app.doc.admin" :key="name"
>
<template #title> <template #title>
<YIcon iname="book" class="mr-2" /> <YIcon iname="book" class="mr-2" />
{{ name === "admin" ? $t('app.doc.admin.title') : name }} {{ name === 'admin' ? $t('app.doc.admin.title') : name }}
</template> </template>
<VueShowdown :markdown="content" flavor="github" /> <VueShowdown :markdown="content" flavor="github" />
</BTab> </BTab>
@ -225,21 +282,34 @@
<YCard <YCard
v-if="app && app.integration" v-if="app && app.integration"
id="app-integration" :title="$t('app.integration.title')" id="app-integration"
collapsable collapsed no-body :title="$t('app.integration.title')"
collapsable
collapsed
no-body
> >
<BListGroup flush> <BListGroup flush>
<YListGroupItem variant="info"> <YListGroupItem variant="info">
{{ $t('app.integration.archs') }} {{ app.integration.archs }} {{ $t('app.integration.archs') }} {{ app.integration.archs }}
</YListGroupItem> </YListGroupItem>
<YListGroupItem v-if="app.integration.ldap" :variant="app.integration.ldap === true ? 'success' : 'warning'"> <YListGroupItem
v-if="app.integration.ldap"
:variant="app.integration.ldap === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.ldap.${app.integration.ldap}`) }} {{ $t(`app.integration.ldap.${app.integration.ldap}`) }}
</YListGroupItem> </YListGroupItem>
<YListGroupItem v-if="app.integration.sso" :variant="app.integration.sso === true ? 'success' : 'warning'"> <YListGroupItem
v-if="app.integration.sso"
:variant="app.integration.sso === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.sso.${app.integration.sso}`) }} {{ $t(`app.integration.sso.${app.integration.sso}`) }}
</YListGroupItem> </YListGroupItem>
<YListGroupItem variant="info"> <YListGroupItem variant="info">
{{ $t(`app.integration.multi_instance.${app.integration.multi_instance}`) }} {{
$t(
`app.integration.multi_instance.${app.integration.multi_instance}`,
)
}}
</YListGroupItem> </YListGroupItem>
<YListGroupItem variant="info"> <YListGroupItem variant="info">
{{ $t('app.integration.resources', app.integration.resources) }} {{ $t('app.integration.resources', app.integration.resources) }}
@ -249,8 +319,12 @@
<YCard <YCard
v-if="app" v-if="app"
id="app-links" icon="link" :title="$t('app.links.title')" id="app-links"
collapsable collapsed no-body icon="link"
:title="$t('app.links.title')"
collapsable
collapsed
no-body
> >
<BListGroup flush> <BListGroup flush>
<YListGroupItem v-for="[key, link] in app.links" :key="key" no-status> <YListGroupItem v-for="[key, link] in app.links" :key="key" no-status>
@ -264,8 +338,11 @@
<BModal <BModal
v-if="app" v-if="app"
id="uninstall-modal" :title="$t('confirm_uninstall', { name: id })" id="uninstall-modal"
header-bg-variant="warning" :body-class="{ 'd-none': !app.supports_purge }" body-bg-variant="" :title="$t('confirm_uninstall', { name: id })"
header-bg-variant="warning"
:body-class="{ 'd-none': !app.supports_purge }"
body-bg-variant=""
@ok="uninstall" @ok="uninstall"
> >
<BFormGroup v-if="app.supports_purge"> <BFormGroup v-if="app.supports_purge">
@ -293,7 +370,7 @@ import { isEmptyValue } from '@/helpers/commons'
import { import {
formatFormData, formatFormData,
formatI18nField, formatI18nField,
formatYunoHostConfigPanels formatYunoHostConfigPanels,
} from '@/helpers/yunohostArguments' } from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels.vue' import ConfigPanels from '@/components/ConfigPanels.vue'
@ -301,19 +378,19 @@ export default {
name: 'AppInfo', name: 'AppInfo',
components: { components: {
ConfigPanels ConfigPanels,
}, },
props: { props: {
id: { type: String, required: true } id: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [
['GET', `apps/${this.id}?full`], ['GET', `apps/${this.id}?full`],
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }], ['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
['GET', { uri: 'domains' }] ['GET', { uri: 'domains' }],
], ],
loading: true, loading: true,
app: undefined, app: undefined,
@ -326,62 +403,66 @@ export default {
{ {
hasApplyButton: false, hasApplyButton: false,
id: 'operations', id: 'operations',
name: this.$i18n.t('operations') name: this.$i18n.t('operations'),
} },
], ],
validations: {} validations: {},
}, },
doc: undefined doc: undefined,
} }
}, },
computed: { computed: {
...mapGetters(['domains']), ...mapGetters(['domains']),
currentTab () { currentTab() {
return this.$route.params.tabId return this.$route.params.tabId
}, },
allowedGroups () { allowedGroups() {
if (!this.app) return if (!this.app) return
return this.app.permissions[0].allowed return this.app.permissions[0].allowed
} },
}, },
validations () { validations() {
return { return {
form: { form: {
labels: { labels: {
$each: { label: { required } } $each: { label: { required } },
}, },
url: { path: { required } } url: { path: { required } },
} },
} }
}, },
methods: { methods: {
appLinksIcons (linkType) { appLinksIcons(linkType) {
const linksIcons = { const linksIcons = {
license: 'institution', license: 'institution',
website: 'globe', website: 'globe',
admindoc: 'book', admindoc: 'book',
userdoc: 'book', userdoc: 'book',
code: 'code', code: 'code',
package: 'code', package: 'code',
package_license: 'institution', package_license: 'institution',
forum: 'comments' forum: 'comments',
} }
return linksIcons[linkType] return linksIcons[linkType]
}, },
async onQueriesResponse (app) { async onQueriesResponse(app) {
const form = { labels: [] } const form = { labels: [] }
const mainPermission = app.permissions[this.id + '.main'] const mainPermission = app.permissions[this.id + '.main']
mainPermission.name = this.id + '.main' mainPermission.name = this.id + '.main'
mainPermission.title = this.$i18n.t('permission_main') mainPermission.title = this.$i18n.t('permission_main')
mainPermission.tileAvailable = mainPermission.url !== null && !mainPermission.url.startsWith('re:') mainPermission.tileAvailable =
form.labels.push({ label: mainPermission.label, show_tile: mainPermission.show_tile }) mainPermission.url !== null && !mainPermission.url.startsWith('re:')
form.labels.push({
label: mainPermission.label,
show_tile: mainPermission.show_tile,
})
const permissions = [mainPermission] const permissions = [mainPermission]
for (const [name, perm] of Object.entries(app.permissions)) { for (const [name, perm] of Object.entries(app.permissions)) {
@ -391,7 +472,7 @@ export default {
name, name,
label: perm.sublabel, label: perm.sublabel,
title: humanPermissionName(name), title: humanPermissionName(name),
tileAvailable: perm.url !== null && !perm.url.startsWith('re:') tileAvailable: perm.url !== null && !perm.url.startsWith('re:'),
}) })
form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile }) form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile })
} }
@ -400,148 +481,214 @@ export default {
const { DESCRIPTION, ADMIN, ...doc } = app.manifest.doc const { DESCRIPTION, ADMIN, ...doc } = app.manifest.doc
const notifs = app.manifest.notifications const notifs = app.manifest.notifications
const { ldap, sso, multi_instance, ram, disk, architectures: archs } = app.manifest.integration const {
ldap,
sso,
multi_instance,
ram,
disk,
architectures: archs,
} = app.manifest.integration
this.app = { this.app = {
id: this.id, id: this.id,
version: app.version, version: app.version,
label: mainPermission.label, label: mainPermission.label,
domain: app.settings.domain, domain: app.settings.domain,
alternativeTo: app.from_catalog.potential_alternative_to?.length alternativeTo: app.from_catalog.potential_alternative_to?.length
? app.from_catalog.potential_alternative_to.join(this.$i18n.t('words.separator')) ? app.from_catalog.potential_alternative_to.join(
: null, this.$i18n.t('words.separator'),
description: DESCRIPTION ? formatI18nField(DESCRIPTION) : app.description, )
integration: app.manifest.packaging_format >= 2
? {
archs: Array.isArray(archs) ? archs.join(this.$i18n.t('words.separator')) : archs,
ldap: ldap === 'not_relevant' ? null : ldap,
sso: sso === 'not_relevant' ? null : sso,
multi_instance,
resources: { ram: ram.runtime, disk }
}
: null, : null,
description: DESCRIPTION
? formatI18nField(DESCRIPTION)
: app.description,
integration:
app.manifest.packaging_format >= 2
? {
archs: Array.isArray(archs)
? archs.join(this.$i18n.t('words.separator'))
: archs,
ldap: ldap === 'not_relevant' ? null : ldap,
sso: sso === 'not_relevant' ? null : sso,
multi_instance,
resources: { ram: ram.runtime, disk },
}
: null,
links: [ links: [
['license', `https://spdx.org/licenses/${app.manifest.upstream.license}`], [
...['website', 'admindoc', 'userdoc', 'code'].map((key) => ([key, app.manifest.upstream[key]])), 'license',
`https://spdx.org/licenses/${app.manifest.upstream.license}`,
],
...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
return [key, app.manifest.upstream[key]]
}),
['package', app.from_catalog.git?.url], ['package', app.from_catalog.git?.url],
['package_license', app.from_catalog.git?.url + '/blob/master/LICENSE'], [
['forum', `https://forum.yunohost.org/tag/${app.manifest.id}`] 'package_license',
app.from_catalog.git?.url + '/blob/master/LICENSE',
],
['forum', `https://forum.yunohost.org/tag/${app.manifest.id}`],
].filter(([key, val]) => !!val), ].filter(([key, val]) => !!val),
doc: { doc: {
notifications: { notifications: {
postInstall: notifs.POST_INSTALL && notifs.POST_INSTALL.main ? [['main', formatI18nField(notifs.POST_INSTALL.main)]] : [], postInstall:
notifs.POST_INSTALL && notifs.POST_INSTALL.main
? [['main', formatI18nField(notifs.POST_INSTALL.main)]]
: [],
postUpgrade: notifs.POST_UPGRADE postUpgrade: notifs.POST_UPGRADE
? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => { ? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => {
return [key, formatI18nField(content)] return [key, formatI18nField(content)]
}) })
: [] : [],
}, },
admin: [ admin: [
['admin', formatI18nField(ADMIN)], ['admin', formatI18nField(ADMIN)],
...Object.keys(doc).sort().map((key) => [key.charAt(0) + key.slice(1).toLowerCase(), formatI18nField(doc[key])]) ...Object.keys(doc)
].filter((doc) => doc[1]) .sort()
.map((key) => [
key.charAt(0) + key.slice(1).toLowerCase(),
formatI18nField(doc[key]),
]),
].filter((doc) => doc[1]),
}, },
is_webapp: app.is_webapp, is_webapp: app.is_webapp,
is_default: app.is_default, is_default: app.is_default,
supports_change_url: app.supports_change_url, supports_change_url: app.supports_change_url,
supports_config_panel: app.supports_config_panel, supports_config_panel: app.supports_config_panel,
supports_purge: app.supports_purge, supports_purge: app.supports_purge,
permissions permissions,
} }
if (app.settings.domain && app.settings.path) { if (app.settings.domain && app.settings.path) {
this.app.url = 'https://' + app.settings.domain + app.settings.path this.app.url = 'https://' + app.settings.domain + app.settings.path
form.url = { form.url = {
domain: app.settings.domain, domain: app.settings.domain,
path: app.settings.path.slice(1) path: app.settings.path.slice(1),
} }
} }
if (!Object.values(this.app.doc.notifications).some((notif) => notif.length)) { if (
!Object.values(this.app.doc.notifications).some((notif) => notif.length)
) {
this.app.doc.notifications = null this.app.doc.notifications = null
} }
if (app.supports_config_panel) { if (app.supports_config_panel) {
await api.get(`apps/${this.id}/config?full`).then((config) => { await api
const config_ = formatYunoHostConfigPanels(config) .get(`apps/${this.id}/config?full`)
// reinject 'operations' fake config tab .then((config) => {
config_.panels.unshift(this.config.panels[0]) const config_ = formatYunoHostConfigPanels(config)
this.config = config_ // reinject 'operations' fake config tab
}).catch((err) => { config_.panels.unshift(this.config.panels[0])
this.config_panel_err = err.message this.config = config_
}) })
.catch((err) => {
this.config_panel_err = err.message
})
} }
this.loading = false this.loading = false
}, },
async onConfigSubmit ({ id, form, action, name }) { async onConfigSubmit({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true }) const args = await formatFormData(form, {
removeEmpty: false,
api.put( removeNull: true,
action
? `apps/${this.id}/actions/${action}`
: `apps/${this.id}/config/${id}`,
isEmptyValue(args) ? {} : { args: objectToParams(args) },
{ key: `apps.${action ? 'action' : 'update'}_config`, id, name: this.id }
).then(() => {
this.loading = true
this.$refs.view.fetchQueries()
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
}) })
api
.put(
action
? `apps/${this.id}/actions/${action}`
: `apps/${this.id}/config/${id}`,
isEmptyValue(args) ? {} : { args: objectToParams(args) },
{
key: `apps.${action ? 'action' : 'update'}_config`,
id,
name: this.id,
},
)
.then(() => {
this.loading = true
this.$refs.view.fetchQueries()
})
.catch((err) => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find((panel) => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
}, },
changeLabel (permName, data) { changeLabel(permName, data) {
data.show_tile = data.show_tile ? 'True' : 'False' data.show_tile = data.show_tile ? 'True' : 'False'
api.put( api
'users/permissions/' + permName, .put('users/permissions/' + permName, data, {
data, key: 'apps.change_label',
{ key: 'apps.change_label', prevName: this.app.label, nextName: data.label } prevName: this.app.label,
).then(this.$refs.view.fetchQueries) nextName: data.label,
})
.then(this.$refs.view.fetchQueries)
}, },
async changeUrl () { async changeUrl() {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_change_url')) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_app_change_url'),
)
if (!confirmed) return if (!confirmed) return
const { domain, path } = this.form.url const { domain, path } = this.form.url
api.put( api
`apps/${this.id}/changeurl`, .put(
{ domain, path: '/' + path }, `apps/${this.id}/changeurl`,
{ key: 'apps.change_url', name: this.app.label } { domain, path: '/' + path },
).then(this.$refs.view.fetchQueries) { key: 'apps.change_url', name: this.app.label },
)
.then(this.$refs.view.fetchQueries)
}, },
async setAsDefaultDomain (undo = false) { async setAsDefaultDomain(undo = false) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_default')) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_app_default'),
)
if (!confirmed) return if (!confirmed) return
api.put( api
`apps/${this.id}/default${undo ? '?undo' : ''}`, .put(
{}, `apps/${this.id}/default${undo ? '?undo' : ''}`,
{ key: 'apps.set_default', name: this.app.label, domain: this.app.domain } {},
).then(this.$refs.view.fetchQueries) {
key: 'apps.set_default',
name: this.app.label,
domain: this.app.domain,
},
)
.then(this.$refs.view.fetchQueries)
}, },
async dismissNotification (name) { async dismissNotification(name) {
api.put( api
`apps/${this.id}/dismiss_notification/${name}`, .put(
{}, `apps/${this.id}/dismiss_notification/${name}`,
{ key: 'apps.dismiss_notification', name: this.app.label } {},
).then(this.$refs.view.fetchQueries) { key: 'apps.dismiss_notification', name: this.app.label },
)
.then(this.$refs.view.fetchQueries)
}, },
async uninstall () { async uninstall() {
const data = this.purge === true ? { purge: 1 } : {} const data = this.purge === true ? { purge: 1 } : {}
api.delete('apps/' + this.id, data, { key: 'apps.uninstall', name: this.app.label }).then(() => { api
this.$router.push({ name: 'app-list' }) .delete('apps/' + this.id, data, {
}) key: 'apps.uninstall',
} name: this.app.label,
})
.then(() => {
this.$router.push({ name: 'app-list' })
})
},
}, },
mixins: [validationMixin] mixins: [validationMixin],
} }
</script> </script>

View file

@ -9,8 +9,10 @@
<BButton <BButton
v-if="app.demo" v-if="app.demo"
:href="app.demo" target="_blank" :href="app.demo"
variant="primary" class="ml-auto" target="_blank"
variant="primary"
class="ml-auto"
> >
<YIcon iname="external-link" /> <YIcon iname="external-link" />
{{ $t('app.install.try_demo') }} {{ $t('app.install.try_demo') }}
@ -18,7 +20,7 @@
</div> </div>
<p class="text-secondary"> <p class="text-secondary">
{{ $t('app.install.version', { version: app.version }) }}<br> {{ $t('app.install.version', { version: app.version }) }}<br />
<template v-if="app.alternativeTo"> <template v-if="app.alternativeTo">
{{ $t('app.potential_alternative_to') }} {{ app.alternativeTo }} {{ $t('app.potential_alternative_to') }} {{ app.alternativeTo }}
@ -30,27 +32,42 @@
<BImg <BImg
v-if="app.screenshot" v-if="app.screenshot"
:src="app.screenshot" :src="app.screenshot"
aria-hidden="true" class="d-block" fluid aria-hidden="true"
class="d-block"
fluid
/> />
</section> </section>
<YCard <YCard
v-if="app.integration" v-if="app.integration"
id="app-integration" :title="$t('app.integration.title')" id="app-integration"
collapsable collapsed no-body :title="$t('app.integration.title')"
collapsable
collapsed
no-body
> >
<BListGroup flush> <BListGroup flush>
<YListGroupItem variant="info"> <YListGroupItem variant="info">
{{ $t('app.integration.archs') }} {{ app.integration.archs }} {{ $t('app.integration.archs') }} {{ app.integration.archs }}
</YListGroupItem> </YListGroupItem>
<YListGroupItem v-if="app.integration.ldap" :variant="app.integration.ldap === true ? 'success' : 'warning'"> <YListGroupItem
v-if="app.integration.ldap"
:variant="app.integration.ldap === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.ldap.${app.integration.ldap}`) }} {{ $t(`app.integration.ldap.${app.integration.ldap}`) }}
</YListGroupItem> </YListGroupItem>
<YListGroupItem v-if="app.integration.sso" :variant="app.integration.sso === true ? 'success' : 'warning'"> <YListGroupItem
v-if="app.integration.sso"
:variant="app.integration.sso === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.sso.${app.integration.sso}`) }} {{ $t(`app.integration.sso.${app.integration.sso}`) }}
</YListGroupItem> </YListGroupItem>
<YListGroupItem variant="info"> <YListGroupItem variant="info">
{{ $t(`app.integration.multi_instance.${app.integration.multi_instance}`) }} {{
$t(
`app.integration.multi_instance.${app.integration.multi_instance}`,
)
}}
</YListGroupItem> </YListGroupItem>
<YListGroupItem variant="info"> <YListGroupItem variant="info">
{{ $t('app.integration.resources', app.integration.resources) }} {{ $t('app.integration.resources', app.integration.resources) }}
@ -59,8 +76,12 @@
</YCard> </YCard>
<YCard <YCard
id="app-links" icon="link" :title="$t('app.links.title')" id="app-links"
collapsable collapsed no-body icon="link"
:title="$t('app.links.title')"
collapsable
collapsed
no-body
> >
<template #header> <template #header>
<h2><YIcon iname="link" /> {{ $t('app.links.title') }}</h2> <h2><YIcon iname="link" /> {{ $t('app.links.title') }}</h2>
@ -94,14 +115,23 @@
</dl> </dl>
</template> </template>
<p v-if="app.quality.state === 'lowquality'" v-t="'app.install.problems.lowquality'" /> <p
v-if="app.quality.state === 'lowquality'"
v-t="'app.install.problems.lowquality'"
/>
<VueShowdown v-if="app.preInstall" :markdown="app.preInstall" flavor="github" /> <VueShowdown
v-if="app.preInstall"
:markdown="app.preInstall"
flavor="github"
/>
</YAlert> </YAlert>
<YAlert <YAlert
v-if="!app.hasSupport" v-if="!app.hasSupport"
variant="danger" icon="warning" class="my-4" variant="danger"
icon="warning"
class="my-4"
> >
<h2>{{ $t('app.install.notifs.pre.critical') }}</h2> <h2>{{ $t('app.install.notifs.pre.critical') }}</h2>
@ -109,35 +139,58 @@
{{ $t('app.install.problems.arch', app.requirements.arch.values) }} {{ $t('app.install.problems.arch', app.requirements.arch.values) }}
</p> </p>
<p v-if="!app.requirements.install.pass"> <p v-if="!app.requirements.install.pass">
{{ $t('app.install.problems.install', app.requirements.install.values) }} {{
$t('app.install.problems.install', app.requirements.install.values)
}}
</p> </p>
<p v-if="!app.requirements.required_yunohost_version.pass"> <p v-if="!app.requirements.required_yunohost_version.pass">
{{ $t('app.install.problems.version', app.requirements.required_yunohost_version.values) }} {{
$t(
'app.install.problems.version',
app.requirements.required_yunohost_version.values,
)
}}
</p> </p>
</YAlert> </YAlert>
<YAlert v-else-if="app.hasDanger" variant="danger" class="my-4"> <YAlert v-else-if="app.hasDanger" variant="danger" class="my-4">
<h2>{{ $t('app.install.notifs.pre.danger') }}</h2> <h2>{{ $t('app.install.notifs.pre.danger') }}</h2>
<p v-if="['inprogress', 'broken', 'thirdparty'].includes(app.quality.state)" v-t="'app.install.problems.' + app.quality.state" /> <p
v-if="
['inprogress', 'broken', 'thirdparty'].includes(app.quality.state)
"
v-t="'app.install.problems.' + app.quality.state"
/>
<p v-if="!app.requirements.ram.pass"> <p v-if="!app.requirements.ram.pass">
{{ $t('app.install.problems.ram', app.requirements.ram.values) }} {{ $t('app.install.problems.ram', app.requirements.ram.values) }}
</p> </p>
<CheckboxItem v-model="force" id="force-install" :label="$t('app.install.problems.ignore')" /> <CheckboxItem
v-model="force"
id="force-install"
:label="$t('app.install.problems.ignore')"
/>
</YAlert> </YAlert>
<!-- INSTALL FORM --> <!-- INSTALL FORM -->
<CardForm <CardForm
v-if="app.canInstall || force" v-if="app.canInstall || force"
:title="$t('app_install_parameters')" icon="cog" :submit-text="$t('install')" :title="$t('app_install_parameters')"
:validation="$v" :server-error="serverError" icon="cog"
:submit-text="$t('install')"
:validation="$v"
:server-error="serverError"
@submit.prevent="performInstall" @submit.prevent="performInstall"
> >
<template v-for="(field, fname) in fields"> <template v-for="(field, fname) in fields">
<Component <Component
v-if="field.visible" :is="field.is" v-bind="field.props" v-if="field.visible"
v-model="form[fname]" :validation="$v.form[fname]" :key="fname" :is="field.is"
v-bind="field.props"
v-model="form[fname]"
:validation="$v.form[fname]"
:key="fname"
/> />
</template> </template>
</CardForm> </CardForm>
@ -145,7 +198,8 @@
<!-- In case of a custom url with no manifest found --> <!-- In case of a custom url with no manifest found -->
<BAlert v-else-if="app === null" variant="warning"> <BAlert v-else-if="app === null" variant="warning">
<YIcon iname="exclamation-triangle" /> {{ $t('app_install_custom_no_manifest') }} <YIcon iname="exclamation-triangle" />
{{ $t('app_install_custom_no_manifest') }}
</BAlert> </BAlert>
<template #skeleton> <template #skeleton>
@ -162,7 +216,7 @@ import api, { objectToParams } from '@/api'
import { import {
formatYunoHostArguments, formatYunoHostArguments,
formatI18nField, formatI18nField,
formatFormData formatFormData,
} from '@/helpers/yunohostArguments' } from '@/helpers/yunohostArguments'
import CardCollapse from '@/components/CardCollapse.vue' import CardCollapse from '@/components/CardCollapse.vue'
@ -172,18 +226,18 @@ export default {
mixins: [validationMixin], mixins: [validationMixin],
components: { components: {
CardCollapse CardCollapse,
}, },
props: { props: {
id: { type: String, required: true } id: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [
['GET', 'apps/catalog?full&with_categories&with_antifeatures'], ['GET', 'apps/catalog?full&with_categories&with_antifeatures'],
['GET', `apps/manifest?app=${this.id}&with_screenshot`] ['GET', `apps/manifest?app=${this.id}&with_screenshot`],
], ],
app: undefined, app: undefined,
name: undefined, name: undefined,
@ -192,16 +246,16 @@ export default {
validations: null, validations: null,
errors: undefined, errors: undefined,
serverError: '', serverError: '',
force: false force: false,
} }
}, },
validations () { validations() {
return this.validations return this.validations
}, },
methods: { methods: {
appLinksIcons (linkType) { appLinksIcons(linkType) {
const linksIcons = { const linksIcons = {
license: 'institution', license: 'institution',
website: 'globe', website: 'globe',
@ -210,16 +264,25 @@ export default {
code: 'code', code: 'code',
package: 'code', package: 'code',
package_license: 'institution', package_license: 'institution',
forum: 'comments' forum: 'comments',
} }
return linksIcons[linkType] return linksIcons[linkType]
}, },
onQueriesResponse (catalog, _app) { onQueriesResponse(catalog, _app) {
const antifeaturesList = Object.fromEntries(catalog.antifeatures.map((af) => ([af.id, af]))) const antifeaturesList = Object.fromEntries(
catalog.antifeatures.map((af) => [af.id, af]),
)
const { id, name, version, requirements } = _app const { id, name, version, requirements } = _app
const { ldap, sso, multi_instance, ram, disk, architectures: archs } = _app.integration const {
ldap,
sso,
multi_instance,
ram,
disk,
architectures: archs,
} = _app.integration
const quality = { state: _app.quality.state, variant: 'danger' } const quality = { state: _app.quality.state, variant: 'danger' }
if (quality.state === 'working') { if (quality.state === 'working') {
@ -230,7 +293,8 @@ export default {
quality.variant = 'warning' quality.variant = 'warning'
} else { } else {
quality.variant = 'success' quality.variant = 'success'
quality.state = _app.quality.level >= 8 ? 'highquality' : 'goodquality' quality.state =
_app.quality.level >= 8 ? 'highquality' : 'goodquality'
} }
} }
const preInstall = formatI18nField(_app.notifications.PRE_INSTALL.main) const preInstall = formatI18nField(_app.notifications.PRE_INSTALL.main)
@ -247,38 +311,47 @@ export default {
const app = { const app = {
id, id,
name, name,
alternativeTo: _app.potential_alternative_to && _app.potential_alternative_to.length alternativeTo:
? _app.potential_alternative_to.join(this.$i18n.t('words.separator')) _app.potential_alternative_to && _app.potential_alternative_to.length
: null, ? _app.potential_alternative_to.join(
this.$i18n.t('words.separator'),
)
: null,
description: formatI18nField(_app.doc.DESCRIPTION || _app.description), description: formatI18nField(_app.doc.DESCRIPTION || _app.description),
screenshot: _app.screenshot, screenshot: _app.screenshot,
demo: _app.upstream.demo, demo: _app.upstream.demo,
version, version,
license: _app.upstream.license, license: _app.upstream.license,
integration: _app.packaging_format >= 2 integration:
? { _app.packaging_format >= 2
archs: Array.isArray(archs) ? archs.join(this.$i18n.t('words.separator')) : archs, ? {
ldap: ldap === 'not_relevant' ? null : ldap, archs: Array.isArray(archs)
sso: sso === 'not_relevant' ? null : sso, ? archs.join(this.$i18n.t('words.separator'))
multi_instance, : archs,
resources: { ram: ram.runtime, disk } ldap: ldap === 'not_relevant' ? null : ldap,
} sso: sso === 'not_relevant' ? null : sso,
: null, multi_instance,
resources: { ram: ram.runtime, disk },
}
: null,
links: [ links: [
['license', `https://spdx.org/licenses/${_app.upstream.license}`], ['license', `https://spdx.org/licenses/${_app.upstream.license}`],
...['website', 'admindoc', 'userdoc', 'code'].map((key) => ([key, _app.upstream[key]])), ...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
return [key, _app.upstream[key]]
}),
['package', _app.remote.url], ['package', _app.remote.url],
['package_license', _app.remote.url + '/blob/master/LICENSE'], ['package_license', _app.remote.url + '/blob/master/LICENSE'],
['forum', `https://forum.yunohost.org/tag/${id}`] ['forum', `https://forum.yunohost.org/tag/${id}`],
].filter(([key, val]) => !!val), ].filter(([key, val]) => !!val),
preInstall, preInstall,
antifeatures, antifeatures,
quality, quality,
requirements, requirements,
hasWarning: !!preInstall || antifeatures || quality.variant === 'warning', hasWarning:
!!preInstall || antifeatures || quality.variant === 'warning',
hasDanger, hasDanger,
hasSupport, hasSupport,
canInstall: hasSupport && !hasDanger canInstall: hasSupport && !hasDanger,
} }
// FIXME yunohost should add the label field by default // FIXME yunohost should add the label field by default
@ -286,15 +359,12 @@ export default {
ask: this.$t('label_for_manifestname', { name }), ask: this.$t('label_for_manifestname', { name }),
default: name, default: name,
name: 'label', name: 'label',
help: this.$t('label_for_manifestname_help') help: this.$t('label_for_manifestname_help'),
}) })
const { const { form, fields, validations, errors } = formatYunoHostArguments(
form, _app.install,
fields, )
validations,
errors
} = formatYunoHostArguments(_app.install)
this.app = app this.app = app
this.fields = fields this.fields = fields
@ -303,51 +373,70 @@ export default {
this.errors = errors this.errors = errors
}, },
formatAppNotifs (notifs) { formatAppNotifs(notifs) {
return Object.keys(notifs).reduce((acc, key) => { return Object.keys(notifs).reduce((acc, key) => {
return acc + '\n\n' + notifs[key] return acc + '\n\n' + notifs[key]
}, '') }, '')
}, },
async performInstall () { async performInstall() {
if ('path' in this.form && this.form.path === '/') { if ('path' in this.form && this.form.path === '/') {
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_install_domain_root', { domain: this.form.domain }) this.$i18n.t('confirm_install_domain_root', {
domain: this.form.domain,
}),
) )
if (!confirmed) return if (!confirmed) return
} }
const { data: args, label } = await formatFormData( const { data: args, label } = await formatFormData(this.form, {
this.form, extract: ['label'],
{ extract: ['label'], removeEmpty: false, removeNull: true } removeEmpty: false,
) removeNull: true,
const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }
api.post('apps', data, { key: 'apps.install', name: this.app.name }).then(async ({ notifications }) => {
const postInstall = this.formatAppNotifs(notifications)
if (postInstall) {
const message = this.$i18n.t('app.install.notifs.post.alert') + '\n\n' + postInstall
await this.$askMdConfirmation(message, {
title: this.$i18n.t('app.install.notifs.post.title', { name: this.app.name }),
okTitle: this.$i18n.t('ok')
}, true)
}
this.$router.push({ name: 'app-list' })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
if (err.data.name) {
this.errors[err.data.name].message = err.message
} else this.serverError = err.message
}) })
} const data = {
} app: this.id,
label,
args: Object.entries(args).length ? objectToParams(args) : undefined,
}
api
.post('apps', data, { key: 'apps.install', name: this.app.name })
.then(async ({ notifications }) => {
const postInstall = this.formatAppNotifs(notifications)
if (postInstall) {
const message =
this.$i18n.t('app.install.notifs.post.alert') +
'\n\n' +
postInstall
await this.$askMdConfirmation(
message,
{
title: this.$i18n.t('app.install.notifs.post.title', {
name: this.app.name,
}),
okTitle: this.$i18n.t('ok'),
},
true,
)
}
this.$router.push({ name: 'app-list' })
})
.catch((err) => {
if (err.name !== 'APIBadRequestError') throw err
if (err.data.name) {
this.errors[err.data.name].message = err.message
} else this.serverError = err.message
})
},
},
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.antifeatures { .antifeatures {
dt::before { dt::before {
content: "• "; content: '• ';
} }
} }
</style> </style>

View file

@ -16,8 +16,9 @@
<BListGroup> <BListGroup>
<BListGroupItem <BListGroupItem
v-for="{ id, description, label } in filteredApps" :key="id" v-for="{ id, description, label } in filteredApps"
:to="{ name: 'app-info', params: { id }}" :key="id"
:to="{ name: 'app-info', params: { id } }"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div> <div>
@ -40,40 +41,40 @@
export default { export default {
name: 'AppList', name: 'AppList',
data () { data() {
return { return {
queries: [ queries: [['GET', 'apps?full']],
['GET', 'apps?full']
],
search: '', search: '',
apps: undefined apps: undefined,
} }
}, },
computed: { computed: {
filteredApps () { filteredApps() {
if (!this.apps) return if (!this.apps) return
const search = this.search.toLowerCase() const search = this.search.toLowerCase()
const match = (item) => item && item.toLowerCase().includes(search) const match = (item) => item && item.toLowerCase().includes(search)
// Check if any value in apps (label, id, name, description) match the search query. // Check if any value in apps (label, id, name, description) match the search query.
const filtered = this.apps.filter(app => Object.values(app).some(match)) const filtered = this.apps.filter((app) => Object.values(app).some(match))
return filtered.length ? filtered : null return filtered.length ? filtered : null
} },
}, },
methods: { methods: {
onQueriesResponse ({ apps }) { onQueriesResponse({ apps }) {
if (apps.length === 0) { if (apps.length === 0) {
this.apps = null this.apps = null
return return
} }
this.apps = apps.map(({ id, name, description, manifest }) => { this.apps = apps
return { id, name: manifest.name, label: name, description } .map(({ id, name, description, manifest }) => {
}).sort((prev, app) => { return { id, name: manifest.name, label: name, description }
return prev.label > app.label ? 1 : -1 })
}) .sort((prev, app) => {
} return prev.label > app.label ? 1 : -1
} })
},
},
} }
</script> </script>

View file

@ -1,34 +1,46 @@
<template> <template>
<ViewBase :queries="queries" @queries-response="onQueriesResponse" skeleton="CardListSkeleton"> <ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
skeleton="CardListSkeleton"
>
<!-- FIXME switch to <CardForm> ? --> <!-- FIXME switch to <CardForm> ? -->
<YCard :title="$t('backup_create')" icon="archive" no-body> <YCard :title="$t('backup_create')" icon="archive" no-body>
<BFormCheckboxGroup <BFormCheckboxGroup
v-model="selected" v-model="selected"
id="backup-select" name="backup-select" size="lg" id="backup-select"
name="backup-select"
size="lg"
> >
<BListGroup flush> <BListGroup flush>
<!-- SYSTEM HEADER --> <!-- SYSTEM HEADER -->
<BListGroupItem class="d-flex align-items-sm-center flex-column flex-sm-row text-primary"> <BListGroupItem
<h4 class="m-0"> class="d-flex align-items-sm-center flex-column flex-sm-row text-primary"
<YIcon iname="cube" /> {{ $t('system') }} >
</h4> <h4 class="m-0"><YIcon iname="cube" /> {{ $t('system') }}</h4>
<div class="ml-sm-auto mt-2 mt-sm-0"> <div class="ml-sm-auto mt-2 mt-sm-0">
<BButton <BButton
@click="toggleSelected(true, 'system')" v-t="'select_all'" @click="toggleSelected(true, 'system')"
size="sm" variant="outline-dark" v-t="'select_all'"
size="sm"
variant="outline-dark"
/> />
<BButton <BButton
@click="toggleSelected(false, 'system')" v-t="'select_none'" @click="toggleSelected(false, 'system')"
size="sm" variant="outline-dark" class="ml-2" v-t="'select_none'"
size="sm"
variant="outline-dark"
class="ml-2"
/> />
</div> </div>
</BListGroupItem> </BListGroupItem>
<!-- SYSTEM ITEMS --> <!-- SYSTEM ITEMS -->
<BListGroupItem <BListGroupItem
v-for="(item, partName) in system" :key="partName" v-for="(item, partName) in system"
:key="partName"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
@ -40,43 +52,60 @@
</p> </p>
</div> </div>
<BFormCheckbox :value="partName" :aria-label="$t('check')" class="d-inline" /> <BFormCheckbox
:value="partName"
:aria-label="$t('check')"
class="d-inline"
/>
</BListGroupItem> </BListGroupItem>
<!-- APPS HEADER --> <!-- APPS HEADER -->
<BListGroupItem class="d-flex align-items-sm-center flex-column flex-sm-row text-primary"> <BListGroupItem
class="d-flex align-items-sm-center flex-column flex-sm-row text-primary"
>
<h4 class="m-0"> <h4 class="m-0">
<YIcon iname="cubes" /> {{ $t('applications') }} <YIcon iname="cubes" /> {{ $t('applications') }}
</h4> </h4>
<div class="ml-sm-auto mt-2 mt-sm-0"> <div class="ml-sm-auto mt-2 mt-sm-0">
<BButton <BButton
@click="toggleSelected(true, 'apps')" v-t="'select_all'" @click="toggleSelected(true, 'apps')"
size="sm" variant="outline-dark" v-t="'select_all'"
size="sm"
variant="outline-dark"
/> />
<BButton <BButton
@click="toggleSelected(false, 'apps')" v-t="'select_none'" @click="toggleSelected(false, 'apps')"
size="sm" variant="outline-dark" class="ml-2" v-t="'select_none'"
size="sm"
variant="outline-dark"
class="ml-2"
/> />
</div> </div>
</BListGroupItem> </BListGroupItem>
<!-- APPS ITEMS --> <!-- APPS ITEMS -->
<BListGroupItem <BListGroupItem
v-for="(item, appName) in apps" :key="appName" v-for="(item, appName) in apps"
:key="appName"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
<h5 class="font-weight-bold"> <h5 class="font-weight-bold">
{{ item.name }} <small class="text-secondary">{{ item.id }}</small> {{ item.name }}
<small class="text-secondary">{{ item.id }}</small>
</h5> </h5>
<p class="m-0"> <p class="m-0">
{{ item.description }} {{ item.description }}
</p> </p>
</div> </div>
<BFormCheckbox :value="appName" :aria-label="$t('check')" class="d-inline" /> <BFormCheckbox
:value="appName"
:aria-label="$t('check')"
class="d-inline"
/>
</BListGroupItem> </BListGroupItem>
</BListGroup> </BListGroup>
</BFormCheckboxGroup> </BFormCheckboxGroup>
@ -84,8 +113,10 @@
<!-- SUBMIT --> <!-- SUBMIT -->
<template #buttons> <template #buttons>
<BButton <BButton
@click="createBackup" v-t="'backup_action'" @click="createBackup"
variant="success" :disabled="selected.length === 0" v-t="'backup_action'"
variant="success"
:disabled="selected.length === 0"
/> />
</template> </template>
</YCard> </YCard>
@ -99,27 +130,29 @@ export default {
name: 'BackupCreate', name: 'BackupCreate',
props: { props: {
id: { type: String, required: true } id: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [
['GET', 'hooks/backup'], ['GET', 'hooks/backup'],
['GET', 'apps?with_backup'] ['GET', 'apps?with_backup'],
], ],
selected: [], selected: [],
// api data // api data
system: undefined, system: undefined,
apps: undefined apps: undefined,
} }
}, },
methods: { methods: {
formatHooks (hooks) { formatHooks(hooks) {
const data = {} const data = {}
hooks.forEach(hook => { hooks.forEach((hook) => {
const groupId = hook.startsWith('conf_') ? 'adminjs_group_configuration' : hook const groupId = hook.startsWith('conf_')
? 'adminjs_group_configuration'
: hook
if (groupId in data) { if (groupId in data) {
data[groupId].value.push(hook) data[groupId].value.push(hook)
data[groupId].description += ', ' + this.$i18n.t('hook_' + hook) data[groupId].description += ', ' + this.$i18n.t('hook_' + hook)
@ -127,14 +160,16 @@ export default {
data[groupId] = { data[groupId] = {
name: this.$i18n.t('hook_' + groupId), name: this.$i18n.t('hook_' + groupId),
value: [hook], value: [hook],
description: this.$i18n.t(groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook) description: this.$i18n.t(
groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook,
),
} }
} }
}) })
return data return data
}, },
onQueriesResponse ({ hooks }, { apps }) { onQueriesResponse({ hooks }, { apps }) {
this.system = this.formatHooks(hooks) this.system = this.formatHooks(hooks)
// transform app array into literal object to match hooks data structure // transform app array into literal object to match hooks data structure
this.apps = apps.reduce((obj, app) => { this.apps = apps.reduce((obj, app) => {
@ -144,17 +179,21 @@ export default {
this.selected = [...Object.keys(this.system), ...Object.keys(this.apps)] this.selected = [...Object.keys(this.system), ...Object.keys(this.apps)]
}, },
toggleSelected (select, type) { toggleSelected(select, type) {
if (select) { if (select) {
const toSelect = Object.keys(this[type]).filter(item => !this.selected.includes(item)) const toSelect = Object.keys(this[type]).filter(
(item) => !this.selected.includes(item),
)
this.selected = [...this.selected, ...toSelect] this.selected = [...this.selected, ...toSelect]
} else { } else {
const toUnselect = Object.keys(this[type]) const toUnselect = Object.keys(this[type])
this.selected = this.selected.filter(selected => !toUnselect.includes(selected)) this.selected = this.selected.filter(
(selected) => !toUnselect.includes(selected),
)
} }
}, },
createBackup () { createBackup() {
const data = { apps: [], system: [] } const data = { apps: [], system: [] }
for (const item of this.selected) { for (const item of this.selected) {
if (item in this.system) { if (item in this.system) {
@ -167,7 +206,7 @@ export default {
api.post('backups', data, 'backups.create').then(() => { api.post('backups', data, 'backups.create').then(() => {
this.$router.push({ name: 'backup-list', params: { id: this.id } }) this.$router.push({ name: 'backup-list', params: { id: this.id } })
}) })
} },
} },
} }
</script> </script>

View file

@ -15,8 +15,10 @@
</template> </template>
<BRow <BRow
v-for="(value, prop) in infos" :key="prop" v-for="(value, prop) in infos"
no-gutters class="row-line" :key="prop"
no-gutters
class="row-line"
> >
<BCol md="3" xl="2"> <BCol md="3" xl="2">
<strong>{{ $t(prop === 'name' ? 'id' : prop) }}</strong> <strong>{{ $t(prop === 'name' ? 'id' : prop) }}</strong>
@ -32,35 +34,48 @@
<!-- BACKUP CONTENT --> <!-- BACKUP CONTENT -->
<!-- FIXME switch to <CardForm> ? --> <!-- FIXME switch to <CardForm> ? -->
<YCard <YCard
:title="$t('backup_content')" icon="archive" :title="$t('backup_content')"
no-body button-unbreak="sm" icon="archive"
no-body
button-unbreak="sm"
> >
<template #header-buttons> <template #header-buttons>
<BButton <BButton
size="sm" variant="outline-secondary" size="sm"
@click="toggleSelected()" v-t="'select_all'" variant="outline-secondary"
@click="toggleSelected()"
v-t="'select_all'"
/> />
<BButton <BButton
size="sm" variant="outline-secondary" size="sm"
@click="toggleSelected(false)" v-t="'select_none'" variant="outline-secondary"
@click="toggleSelected(false)"
v-t="'select_none'"
/> />
</template> </template>
<BFormCheckboxGroup <BFormCheckboxGroup
v-if="hasBackupData" v-model="selected" v-if="hasBackupData"
id="backup-select" name="backup-select" size="lg" v-model="selected"
id="backup-select"
name="backup-select"
size="lg"
aria-describedby="backup-restore-feedback" aria-describedby="backup-restore-feedback"
> >
<BListGroup flush> <BListGroup flush>
<!-- SYSTEM PARTS --> <!-- SYSTEM PARTS -->
<BListGroupItem <BListGroupItem
v-for="(item, partName) in system" :key="partName" v-for="(item, partName) in system"
:key="partName"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
<h5 class="font-weight-bold"> <h5 class="font-weight-bold">
{{ item.name }} <small class="text-secondary" v-if="item.size">({{ humanSize(item.size) }})</small> {{ item.name }}
<small class="text-secondary" v-if="item.size">
({{ humanSize(item.size) }})
</small>
</h5> </h5>
<p class="m-0"> <p class="m-0">
{{ item.description }} {{ item.description }}
@ -72,16 +87,18 @@
<!-- APPS --> <!-- APPS -->
<BListGroupItem <BListGroupItem
v-for="(item, appName) in apps" :key="appName" v-for="(item, appName) in apps"
:key="appName"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div class="mr-2"> <div class="mr-2">
<h5 class="font-weight-bold"> <h5 class="font-weight-bold">
{{ item.name }} <small class="text-secondary">{{ appName }} ({{ humanSize(item.size) }})</small> {{ item.name }}
<small class="text-secondary">
{{ appName }} ({{ humanSize(item.size) }})
</small>
</h5> </h5>
<p class="m-0"> <p class="m-0">{{ $t('version') }} {{ item.version }}</p>
{{ $t('version') }} {{ item.version }}
</p>
</div> </div>
<BFormCheckbox :value="appName" :aria-label="$t('check')" /> <BFormCheckbox :value="appName" :aria-label="$t('check')" />
@ -102,8 +119,11 @@
<!-- SUBMIT --> <!-- SUBMIT -->
<template v-if="hasBackupData" #buttons> <template v-if="hasBackupData" #buttons>
<BButton <BButton
@click="restoreBackup" form="backup-restore" variant="success" @click="restoreBackup"
v-t="'restore'" :disabled="selected.length === 0" form="backup-restore"
variant="success"
v-t="'restore'"
:disabled="selected.length === 0"
/> />
</template> </template>
</YCard> </YCard>
@ -126,35 +146,35 @@ export default {
props: { props: {
id: { type: String, required: true }, id: { type: String, required: true },
name: { type: String, required: true } name: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [['GET', `backups/${this.name}?with_details`]],
['GET', `backups/${this.name}?with_details`]
],
selected: [], selected: [],
error: '', error: '',
isValid: null, isValid: null,
// api data // api data
infos: undefined, infos: undefined,
apps: undefined, apps: undefined,
system: undefined system: undefined,
} }
}, },
computed: { computed: {
hasBackupData () { hasBackupData() {
return !isEmptyValue(this.system) || !isEmptyValue(this.apps) return !isEmptyValue(this.system) || !isEmptyValue(this.apps)
} },
}, },
methods: { methods: {
formatHooks (hooks) { formatHooks(hooks) {
const data = {} const data = {}
Object.entries(hooks).forEach(([hook, { size }]) => { Object.entries(hooks).forEach(([hook, { size }]) => {
const groupId = hook.startsWith('conf_') ? 'adminjs_group_configuration' : hook const groupId = hook.startsWith('conf_')
? 'adminjs_group_configuration'
: hook
if (groupId in data) { if (groupId in data) {
data[groupId].value.push(hook) data[groupId].value.push(hook)
data[groupId].description += ', ' + this.$i18n.t('hook_' + hook) data[groupId].description += ', ' + this.$i18n.t('hook_' + hook)
@ -163,20 +183,22 @@ export default {
data[groupId] = { data[groupId] = {
name: this.$i18n.t('hook_' + groupId), name: this.$i18n.t('hook_' + groupId),
value: [hook], value: [hook],
description: this.$i18n.t(groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook), description: this.$i18n.t(
size groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook,
),
size,
} }
} }
}) })
return data return data
}, },
onQueriesResponse (data) { onQueriesResponse(data) {
this.infos = { this.infos = {
name: this.name, name: this.name,
created_at: data.created_at, created_at: data.created_at,
size: data.size, size: data.size,
path: data.path path: data.path,
} }
this.system = this.formatHooks(data.system) this.system = this.formatHooks(data.system)
this.apps = data.apps this.apps = data.apps
@ -184,20 +206,17 @@ export default {
this.toggleSelected() this.toggleSelected()
}, },
toggleSelected (select = true) { toggleSelected(select = true) {
if (select) { if (select) {
this.selected = [ this.selected = [...Object.keys(this.apps), ...Object.keys(this.system)]
...Object.keys(this.apps),
...Object.keys(this.system)
]
} else { } else {
this.selected = [] this.selected = []
} }
}, },
async restoreBackup () { async restoreBackup() {
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_restore', { name: this.name }) this.$i18n.t('confirm_restore', { name: this.name }),
) )
if (!confirmed) return if (!confirmed) return
@ -210,35 +229,48 @@ export default {
} }
} }
api.put( api
`backups/${this.name}/restore`, data, { key: 'backups.restore', name: this.name } .put(`backups/${this.name}/restore`, data, {
).then(() => { key: 'backups.restore',
this.isValid = null name: this.name,
}).catch(err => { })
if (err.name !== 'APIBadRequestError') throw err .then(() => {
this.error = err.message this.isValid = null
this.isValid = false })
}) .catch((err) => {
if (err.name !== 'APIBadRequestError') throw err
this.error = err.message
this.isValid = false
})
}, },
async deleteBackup () { async deleteBackup() {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name })) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_delete', { name: this.name }),
)
if (!confirmed) return if (!confirmed) return
api.delete( api
'backups/' + this.name, {}, { key: 'backups.delete', name: this.name } .delete(
).then(() => { 'backups/' + this.name,
this.$router.push({ name: 'backup-list', params: { id: this.id } }) {},
}) { key: 'backups.delete', name: this.name },
)
.then(() => {
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
}, },
downloadBackup () { downloadBackup() {
const host = this.$store.getters.host const host = this.$store.getters.host
window.open(`https://${host}/yunohost/api/backups/${this.name}/download`, '_blank') window.open(
`https://${host}/yunohost/api/backups/${this.name}/download`,
'_blank',
)
}, },
readableDate, readableDate,
humanSize humanSize,
} },
} }
</script> </script>

View file

@ -1,7 +1,17 @@
<template> <template>
<ViewBase :queries="queries" @queries-response="onQueriesResponse" skeleton="ListGroupSkeleton"> <ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
skeleton="ListGroupSkeleton"
>
<template #top> <template #top>
<TopBar :button="{ text: $t('backup_new'), icon: 'plus', to: { name: 'backup-create' } }" /> <TopBar
:button="{
text: $t('backup_new'),
icon: 'plus',
to: { name: 'backup-create' },
}"
/>
</template> </template>
<BAlert v-if="!archives" variant="warning"> <BAlert v-if="!archives" variant="warning">
@ -11,15 +21,18 @@
<BListGroup v-else> <BListGroup v-else>
<BListGroupItem <BListGroupItem
v-for="{ name, created_at, path, size } in archives" :key="name" v-for="{ name, created_at, path, size } in archives"
:to="{ name: 'backup-info', params: { name, id }}" :key="name"
:to="{ name: 'backup-info', params: { name, id } }"
:title="readableDate(created_at)" :title="readableDate(created_at)"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div> <div>
<h5 class="font-weight-bold"> <h5 class="font-weight-bold">
{{ distanceToNow(created_at) }} {{ distanceToNow(created_at) }}
<small class="text-secondary">{{ name }} ({{ humanSize(size) }})</small> <small class="text-secondary"
>{{ name }} ({{ humanSize(size) }})</small
>
</h5> </h5>
<p class="mb-0"> <p class="mb-0">
{{ path }} {{ path }}
@ -39,26 +52,26 @@ export default {
name: 'BackupList', name: 'BackupList',
props: { props: {
id: { type: String, required: true } id: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [['GET', 'backups?with_info']],
['GET', 'backups?with_info'] archives: undefined,
],
archives: undefined
} }
}, },
methods: { methods: {
onQueriesResponse (data) { onQueriesResponse(data) {
const archives = Object.entries(data.archives) const archives = Object.entries(data.archives)
if (archives.length) { if (archives.length) {
this.archives = archives.map(([name, infos]) => { this.archives = archives
infos.name = name .map(([name, infos]) => {
return infos infos.name = name
}).reverse() return infos
})
.reverse()
} else { } else {
this.archives = null this.archives = null
} }
@ -66,7 +79,7 @@ export default {
distanceToNow, distanceToNow,
readableDate, readableDate,
humanSize humanSize,
} },
} }
</script> </script>

View file

@ -2,8 +2,9 @@
<div> <div>
<BListGroup> <BListGroup>
<BListGroupItem <BListGroupItem
v-for="{ id, name, uri } in storages" :key="id" v-for="{ id, name, uri } in storages"
:to="{ name: 'backup-list', params: { id }}" :key="id"
:to="{ name: 'backup-list', params: { id } }"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div> <div>
@ -25,16 +26,16 @@
export default { export default {
name: 'BackupView', name: 'BackupView',
data () { data() {
return { return {
storages: [ storages: [
{ {
id: 'local', id: 'local',
name: this.$i18n.t('local_archives'), name: this.$i18n.t('local_archives'),
uri: '/home/yunohost.backup/' uri: '/home/yunohost.backup/',
} },
] ],
} }
} },
} }
</script> </script>

View file

@ -1,6 +1,8 @@
<template> <template>
<ViewBase <ViewBase
:queries="queries" @queries-response="onQueriesResponse" queries-wait :queries="queries"
@queries-response="onQueriesResponse"
queries-wait
ref="view" ref="view"
> >
<template #top-bar-group-right> <template #top-bar-group-right>
@ -13,7 +15,9 @@
<div class="alert alert-info"> <div class="alert alert-info">
{{ $t(reports ? 'diagnosis_explanation' : 'diagnosis_first_run') }} {{ $t(reports ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
<BButton <BButton
v-if="reports === null" class="d-block mt-2" variant="info" v-if="reports === null"
class="d-block mt-2"
variant="info"
@click="runDiagnosis()" @click="runDiagnosis()"
> >
<YIcon iname="stethoscope" /> {{ $t('run_first_diagnosis') }} <YIcon iname="stethoscope" /> {{ $t('run_first_diagnosis') }}
@ -23,24 +27,46 @@
<!-- REPORT CARD --> <!-- REPORT CARD -->
<YCard <YCard
v-for="report in reports" :key="report.id" v-for="report in reports"
collapsable :collapsed="report.noIssues" :key="report.id"
no-body button-unbreak="lg" collapsable
:collapsed="report.noIssues"
no-body
button-unbreak="lg"
> >
<!-- REPORT HEADER --> <!-- REPORT HEADER -->
<template #header> <template #header>
<h2>{{ report.description }}</h2> <h2>{{ report.description }}</h2>
<div class=""> <div class="">
<BBadge v-if="report.noIssues" variant="success" v-t="'everything_good'" /> <BBadge
<BBadge v-if="report.errors" variant="danger" v-t="{ path: 'issues', args: { count: report.errors } }" /> v-if="report.noIssues"
<BBadge v-if="report.warnings" variant="warning" v-t="{ path: 'warnings', args: { count: report.warnings } }" /> variant="success"
<BBadge v-if="report.ignoreds" v-t="{ path: 'ignored', args: { count: report.ignoreds } }" /> v-t="'everything_good'"
/>
<BBadge
v-if="report.errors"
variant="danger"
v-t="{ path: 'issues', args: { count: report.errors } }"
/>
<BBadge
v-if="report.warnings"
variant="warning"
v-t="{ path: 'warnings', args: { count: report.warnings } }"
/>
<BBadge
v-if="report.ignoreds"
v-t="{ path: 'ignored', args: { count: report.ignoreds } }"
/>
</div> </div>
</template> </template>
<template #header-buttons> <template #header-buttons>
<BButton size="sm" :variant="report.items ? 'info' : 'success'" @click="runDiagnosis(report)"> <BButton
size="sm"
:variant="report.items ? 'info' : 'success'"
@click="runDiagnosis(report)"
>
<YIcon iname="refresh" /> {{ $t('rerun_diagnosis') }} <YIcon iname="refresh" /> {{ $t('rerun_diagnosis') }}
</BButton> </BButton>
</template> </template>
@ -53,21 +79,27 @@
<BListGroup flush> <BListGroup flush>
<!-- REPORT ITEM --> <!-- REPORT ITEM -->
<YListGroupItem <YListGroupItem
v-for="(item, i) in report.items" :key="i" v-for="(item, i) in report.items"
:variant="item.variant" :icon="item.Icon" :faded="item.ignored" :key="i"
:variant="item.variant"
:icon="item.Icon"
:faded="item.ignored"
> >
<div class="item-button d-flex align-items-center"> <div class="item-button d-flex align-items-center">
<p class="mb-0 mr-2" v-html="item.summary" /> <p class="mb-0 mr-2" v-html="item.summary" />
<div class="d-flex flex-column flex-lg-row ml-auto"> <div class="d-flex flex-column flex-lg-row ml-auto">
<BButton <BButton
v-if="item.ignored" size="sm" v-if="item.ignored"
size="sm"
@click="toggleIgnoreIssue('unignore', report, item)" @click="toggleIgnoreIssue('unignore', report, item)"
> >
<YIcon iname="bell" /> {{ $t('unignore') }} <YIcon iname="bell" /> {{ $t('unignore') }}
</BButton> </BButton>
<BButton <BButton
v-else-if="item.issue" variant="warning" size="sm" v-else-if="item.issue"
variant="warning"
size="sm"
@click="toggleIgnoreIssue('ignore', report, item)" @click="toggleIgnoreIssue('ignore', report, item)"
> >
<YIcon iname="bell-slash" /> {{ $t('ignore') }} <YIcon iname="bell-slash" /> {{ $t('ignore') }}
@ -75,7 +107,9 @@
<BButton <BButton
v-if="item.details" v-if="item.details"
size="sm" variant="outline-dark" class="ml-lg-2 mt-2 mt-lg-0" size="sm"
variant="outline-dark"
class="ml-lg-2 mt-2 mt-lg-0"
v-b-toggle="`collapse-${report.id}-item-${i}`" v-b-toggle="`collapse-${report.id}-item-${i}`"
> >
<YIcon iname="level-down" /> {{ $t('details') }} <YIcon iname="level-down" /> {{ $t('details') }}
@ -83,9 +117,16 @@
</div> </div>
</div> </div>
<BCollapse v-if="item.details" :id="`collapse-${report.id}-item-${i}`"> <BCollapse
v-if="item.details"
:id="`collapse-${report.id}-item-${i}`"
>
<ul class="mt-2 pl-4"> <ul class="mt-2 pl-4">
<li v-for="(detail, index) in item.details" :key="index" v-html="detail" /> <li
v-for="(detail, index) in item.details"
:key="index"
v-html="detail"
/>
</ul> </ul>
</BCollapse> </BCollapse>
</YListGroupItem> </YListGroupItem>
@ -114,22 +155,22 @@ import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
export default { export default {
name: 'DiagnosisView', name: 'DiagnosisView',
data () { data() {
return { return {
queries: [ queries: [
['PUT', 'diagnosis/run?except_if_never_ran_yet', {}, 'diagnosis.run'], ['PUT', 'diagnosis/run?except_if_never_ran_yet', {}, 'diagnosis.run'],
['GET', 'diagnosis?full'] ['GET', 'diagnosis?full'],
], ],
reports: undefined reports: undefined,
} }
}, },
computed: { computed: {
...mapGetters(['theme']) ...mapGetters(['theme']),
}, },
methods: { methods: {
onQueriesResponse (_, reportsData) { onQueriesResponse(_, reportsData) {
if (reportsData === null) { if (reportsData === null) {
this.reports = null this.reports = null
return return
@ -142,7 +183,7 @@ export default {
report.ignoreds = 0 report.ignoreds = 0
for (const item of report.items) { for (const item of report.items) {
const status = item.variant = item.status.toLowerCase() const status = (item.variant = item.status.toLowerCase())
item.icon = DEFAULT_STATUS_ICON[status] item.icon = DEFAULT_STATUS_ICON[status]
item.issue = false item.issue = false
@ -164,53 +205,58 @@ export default {
this.reports = reports this.reports = reports
}, },
runDiagnosis ({ id = null, description } = {}) { runDiagnosis({ id = null, description } = {}) {
const param = id !== null ? '?force' : '' const param = id !== null ? '?force' : ''
const data = id !== null ? { categories: [id] } : {} const data = id !== null ? { categories: [id] } : {}
api.put( api
'diagnosis/run' + param, .put('diagnosis/run' + param, data, {
data, key: 'diagnosis.run' + (id !== null ? '_specific' : ''),
{ key: 'diagnosis.run' + (id !== null ? '_specific' : ''), description } description,
).then(this.$refs.view.fetchQueries) })
.then(this.$refs.view.fetchQueries)
}, },
toggleIgnoreIssue (action, report, item) { toggleIgnoreIssue(action, report, item) {
const filterArgs = [report.id].concat(Object.entries(item.meta).map(entries => entries.join('='))) const filterArgs = [report.id].concat(
Object.entries(item.meta).map((entries) => entries.join('=')),
)
api.put( api
'diagnosis/' + action, .put(
{ filter: filterArgs }, 'diagnosis/' + action,
`diagnosis.${action}.${item.status.toLowerCase()}` { filter: filterArgs },
).then(() => { `diagnosis.${action}.${item.status.toLowerCase()}`,
item.ignored = action === 'ignore' )
if (item.ignored) { .then(() => {
report[item.status.toLowerCase() + 's']-- item.ignored = action === 'ignore'
} else { if (item.ignored) {
report.ignoreds-- report[item.status.toLowerCase() + 's']--
} } else {
this.formatReportItem(report, item) report.ignoreds--
}) }
this.formatReportItem(report, item)
})
}, },
shareLogs () { shareLogs() {
api.get('diagnosis?share').then(({ url }) => { api.get('diagnosis?share').then(({ url }) => {
window.open(url, '_blank') window.open(url, '_blank')
}) })
}, },
distanceToNow distanceToNow,
} },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.badge + .badge { .badge + .badge {
margin-left: .5rem margin-left: 0.5rem;
} }
p.last-time-run { p.last-time-run {
margin: .75rem 1rem; margin: 0.75rem 1rem;
} }
.list-group { .list-group {

View file

@ -1,8 +1,10 @@
<template> <template>
<ViewBase :queries="queries" skeleton="CardFormSkeleton"> <ViewBase :queries="queries" skeleton="CardFormSkeleton">
<DomainForm <DomainForm
:title="$t('domain_add')" :server-error="serverError" :title="$t('domain_add')"
@submit="onSubmit" :submit-text="$t('add')" :server-error="serverError"
@submit="onSubmit"
:submit-text="$t('add')"
/> />
</ViewBase> </ViewBase>
</template> </template>
@ -14,29 +16,28 @@ import { DomainForm } from '@/views/_partials'
export default { export default {
name: 'DomainAdd', name: 'DomainAdd',
data () { data() {
return { return {
queries: [ queries: [['GET', { uri: 'domains' }]],
['GET', { uri: 'domains' }] serverError: '',
],
serverError: ''
} }
}, },
methods: { methods: {
onSubmit (data) { onSubmit(data) {
api.post( api
'domains', data, { key: 'domains.add', name: data.domain } .post('domains', data, { key: 'domains.add', name: data.domain })
).then(() => { .then(() => {
this.$store.dispatch('RESET_CACHE_DATA', ['domains']) this.$store.dispatch('RESET_CACHE_DATA', ['domains'])
this.$router.push({ name: 'domain-list' }) this.$router.push({ name: 'domain-list' })
}).catch(err => { })
if (err.name !== 'APIBadRequestError') throw err .catch((err) => {
this.serverError = err.message if (err.name !== 'APIBadRequestError') throw err
}) this.serverError = err.message
} })
},
}, },
components: { DomainForm } components: { DomainForm },
} }
</script> </script>

View file

@ -1,6 +1,8 @@
<template> <template>
<ViewBase <ViewBase
:queries="queries" @queries-response="onQueriesResponse" :loading="loading" :queries="queries"
@queries-response="onQueriesResponse"
:loading="loading"
skeleton="CardInfoSkeleton" skeleton="CardInfoSkeleton"
> >
<section v-if="showAutoConfigCard" class="panel-section"> <section v-if="showAutoConfigCard" class="panel-section">
@ -16,22 +18,49 @@
<!-- AUTO CONFIG CHANGES --> <!-- AUTO CONFIG CHANGES -->
<template v-if="dnsChanges"> <template v-if="dnsChanges">
<div class="mb-3" v-for="{ action, records, icon, variant} in dnsChanges" :key="icon"> <div
class="mb-3"
v-for="{ action, records, icon, variant } in dnsChanges"
:key="icon"
>
<h4 class="mt-4 mb-2"> <h4 class="mt-4 mb-2">
{{ action }} {{ action }}
</h4> </h4>
<div class="log"> <div class="log">
<div <div
v-for="({ name: record, spaces, old_content, content, type, managed_by_yunohost }, i) in records" :key="i" v-for="(
class="records px-2" :class="{ 'ignored': managed_by_yunohost === false && force !== true }" {
:title="managed_by_yunohost === false && force !== true ? $t('domain.dns.auto_config_ignored') : null" name: record,
spaces,
old_content,
content,
type,
managed_by_yunohost,
},
i
) in records"
:key="i"
class="records px-2"
:class="{
ignored: managed_by_yunohost === false && force !== true,
}"
:title="
managed_by_yunohost === false && force !== true
? $t('domain.dns.auto_config_ignored')
: null
"
> >
<YIcon :iname="icon" :class="'text-' + variant" /> <YIcon :iname="icon" :class="'text-' + variant" />
{{ record }} {{ record }}
<span class="bg-dark text-light px-1 rounded">{{ type }}</span>{{ spaces }} <span class="bg-dark text-light px-1 rounded">{{ type }}</span
<span v-if="old_content"><span class="text-danger">{{ old_content }}</span> --> </span> >{{ spaces }}
<span :class="{ 'text-success': old_content }">{{ content }}</span> <span v-if="old_content"
><span class="text-danger">{{ old_content }}</span> -->
</span>
<span :class="{ 'text-success': old_content }">{{
content
}}</span>
</div> </div>
</div> </div>
</div> </div>
@ -48,7 +77,8 @@
<!-- CONFIG ERROR ALERT --> <!-- CONFIG ERROR ALERT -->
<template v-if="dnsErrors && dnsErrors.length"> <template v-if="dnsErrors && dnsErrors.length">
<ReadOnlyAlertItem <ReadOnlyAlertItem
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i" v-for="({ variant, icon, message }, i) in dnsErrors"
:key="i"
:label="message" :label="message"
:type="variant" :type="variant"
:icon="icon" :icon="icon"
@ -75,15 +105,23 @@
</section> </section>
<!-- CURRENT DNS ZONE --> <!-- CURRENT DNS ZONE -->
<section v-if="showAutoConfigCard && dnsZone && dnsZone.length" class="panel-section"> <section
v-if="showAutoConfigCard && dnsZone && dnsZone.length"
class="panel-section"
>
<BCardTitle title-tag="h3"> <BCardTitle title-tag="h3">
{{ $t('domain.dns.auto_config_zone') }} {{ $t('domain.dns.auto_config_zone') }}
</BCardTitle> </BCardTitle>
<div class="log"> <div class="log">
<div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records"> <div
v-for="({ name: record, spaces, content, type }, i) in dnsZone"
:key="'zone-' + i"
class="records"
>
{{ record }} {{ record }}
<span class="bg-dark text-light px-1 rounded">{{ type }}</span>{{ spaces }} <span class="bg-dark text-light px-1 rounded">{{ type }}</span
>{{ spaces }}
<span>{{ content }}</span> <span>{{ content }}</span>
</div> </div>
</div> </div>
@ -113,14 +151,12 @@ export default {
name: 'DomainDns', name: 'DomainDns',
props: { props: {
name: { type: String, required: true } name: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [['GET', `domains/${this.name}/dns/suggest`]],
['GET', `domains/${this.name}/dns/suggest`]
],
loading: true, loading: true,
showAutoConfigCard: true, showAutoConfigCard: true,
showManualConfigCard: false, showManualConfigCard: false,
@ -128,108 +164,121 @@ export default {
dnsChanges: undefined, dnsChanges: undefined,
dnsErrors: undefined, dnsErrors: undefined,
dnsZone: undefined, dnsZone: undefined,
force: null force: null,
} }
}, },
methods: { methods: {
onQueriesResponse (suggestedConfig) { onQueriesResponse(suggestedConfig) {
this.dnsConfig = suggestedConfig this.dnsConfig = suggestedConfig
}, },
getDnsChanges () { getDnsChanges() {
this.loading = true this.loading = true
return api.post( return api
`domains/${this.name}/dns/push?dry_run`, {}, null, { wait: false, websocket: false } .post(`domains/${this.name}/dns/push?dry_run`, {}, null, {
).then(dnsChanges => { wait: false,
function getLongest (arr, key) { websocket: false,
return arr.reduce((acc, obj) => {
if (obj[key].length > acc) return obj[key].length
return acc
}, 0)
}
const changes = []
let canForce = false
const categories = [
{ action: 'create', icon: 'plus', variant: 'success' },
{ action: 'update', icon: 'exchange', variant: 'warning' },
{ action: 'delete', icon: 'minus', variant: 'danger' }
]
categories.forEach(category => {
const records = dnsChanges[category.action]
if (records && records.length > 0) {
const longestName = getLongest(records, 'name')
const longestType = getLongest(records, 'type')
records.forEach(record => {
record.name = record.name + ' '.repeat(longestName - record.name.length + 1)
record.spaces = ' '.repeat(longestType - record.type.length + 1)
if (record.managed_by_yunohost === false) canForce = true
})
changes.push({ ...category, records })
}
}) })
.then((dnsChanges) => {
function getLongest(arr, key) {
return arr.reduce((acc, obj) => {
if (obj[key].length > acc) return obj[key].length
return acc
}, 0)
}
const unchanged = dnsChanges.unchanged const changes = []
if (unchanged) { let canForce = false
const longestName = getLongest(unchanged, 'name') const categories = [
const longestType = getLongest(unchanged, 'type') { action: 'create', icon: 'plus', variant: 'success' },
unchanged.forEach(record => { { action: 'update', icon: 'exchange', variant: 'warning' },
record.name = record.name + ' '.repeat(longestName - record.name.length + 1) { action: 'delete', icon: 'minus', variant: 'danger' },
record.spaces = ' '.repeat(longestType - record.type.length + 1) ]
categories.forEach((category) => {
const records = dnsChanges[category.action]
if (records && records.length > 0) {
const longestName = getLongest(records, 'name')
const longestType = getLongest(records, 'type')
records.forEach((record) => {
record.name =
record.name + ' '.repeat(longestName - record.name.length + 1)
record.spaces = ' '.repeat(longestType - record.type.length + 1)
if (record.managed_by_yunohost === false) canForce = true
})
changes.push({ ...category, records })
}
}) })
this.dnsZone = unchanged
}
this.dnsChanges = changes.length > 0 ? changes : null const unchanged = dnsChanges.unchanged
this.force = canForce ? false : null if (unchanged) {
this.loading = false const longestName = getLongest(unchanged, 'name')
}).catch(err => { const longestType = getLongest(unchanged, 'type')
if (err.name !== 'APIBadRequestError') throw err unchanged.forEach((record) => {
const key = err.data.error_key record.name =
if (key === 'domain_dns_push_managed_in_parent_domain') { record.name + ' '.repeat(longestName - record.name.length + 1)
const message = this.$t(key, err.data) record.spaces = ' '.repeat(longestType - record.type.length + 1)
this.dnsErrors = [{ icon: 'info', variant: 'info', message }] })
} else if (key === 'domain_dns_push_failed_to_authenticate') { this.dnsZone = unchanged
const message = this.$t(key, err.data) }
this.dnsErrors = [{ icon: 'ban', variant: 'danger', message }]
} else { this.dnsChanges = changes.length > 0 ? changes : null
this.showManualConfigCard = true this.force = canForce ? false : null
this.showAutoConfigCard = false this.loading = false
} })
this.loading = false .catch((err) => {
}) if (err.name !== 'APIBadRequestError') throw err
const key = err.data.error_key
if (key === 'domain_dns_push_managed_in_parent_domain') {
const message = this.$t(key, err.data)
this.dnsErrors = [{ icon: 'info', variant: 'info', message }]
} else if (key === 'domain_dns_push_failed_to_authenticate') {
const message = this.$t(key, err.data)
this.dnsErrors = [{ icon: 'ban', variant: 'danger', message }]
} else {
this.showManualConfigCard = true
this.showAutoConfigCard = false
}
this.loading = false
})
}, },
async pushDnsChanges () { async pushDnsChanges() {
if (this.force) { if (this.force) {
const confirmed = await this.$askConfirmation(this.$i18n.t('domain.dns.push_force_confirm')) const confirmed = await this.$askConfirmation(
this.$i18n.t('domain.dns.push_force_confirm'),
)
if (!confirmed) return if (!confirmed) return
} }
api.post( api
`domains/${this.name}/dns/push${this.force ? '?force' : ''}`, .post(
{}, `domains/${this.name}/dns/push${this.force ? '?force' : ''}`,
{ key: 'domains.push_dns_changes', name: this.name } {},
).then(async responseData => { { key: 'domains.push_dns_changes', name: this.name },
await this.getDnsChanges() )
if (!isEmptyValue(responseData)) { .then(async (responseData) => {
this.dnsErrors = Object.keys(responseData).reduce((acc, key) => { await this.getDnsChanges()
const args = key === 'warnings' if (!isEmptyValue(responseData)) {
? { icon: 'warning', variant: 'warning' } this.dnsErrors = Object.keys(responseData).reduce((acc, key) => {
: { icon: 'ban', variant: 'danger' } const args =
responseData[key].forEach(message => acc.push({ ...args, message })) key === 'warnings'
return acc ? { icon: 'warning', variant: 'warning' }
}, []) : { icon: 'ban', variant: 'danger' }
} responseData[key].forEach((message) =>
}) acc.push({ ...args, message }),
} )
return acc
}, [])
}
})
},
}, },
created () { created() {
this.getDnsChanges() this.getDnsChanges()
} },
} }
</script> </script>

View file

@ -1,7 +1,9 @@
<template> <template>
<ViewBase <ViewBase
:queries="queries" @queries-response="onQueriesResponse" :queries="queries"
ref="view" skeleton="CardListSkeleton" @queries-response="onQueriesResponse"
ref="view"
skeleton="CardListSkeleton"
> >
<!-- INFO CARD --> <!-- INFO CARD -->
<YCard v-if="domain" :title="name" icon="globe"> <YCard v-if="domain" :title="name" icon="globe">
@ -19,12 +21,20 @@
<template #header-buttons> <template #header-buttons>
<!-- DEFAULT DOMAIN --> <!-- DEFAULT DOMAIN -->
<BButton v-if="!isMainDomain" @click="setAsDefaultDomain" variant="info"> <BButton
v-if="!isMainDomain"
@click="setAsDefaultDomain"
variant="info"
>
<YIcon iname="star" /> {{ $t('set_default') }} <YIcon iname="star" /> {{ $t('set_default') }}
</BButton> </BButton>
<!-- DELETE DOMAIN --> <!-- DELETE DOMAIN -->
<BButton v-b-modal.delete-modal :disabled="isMainDomain" variant="danger"> <BButton
v-b-modal.delete-modal
:disabled="isMainDomain"
variant="danger"
>
<YIcon iname="trash-o" /> {{ $t('delete') }} <YIcon iname="trash-o" /> {{ $t('delete') }}
</BButton> </BButton>
</template> </template>
@ -40,13 +50,24 @@
<DescriptionRow :term="$t('domain.info.certificate_authority')"> <DescriptionRow :term="$t('domain.info.certificate_authority')">
<YIcon :iname="cert.icon" :variant="cert.variant" class="mr-1" /> <YIcon :iname="cert.icon" :variant="cert.variant" class="mr-1" />
{{ $t('domain.cert.types.' + cert.authority) }} {{ $t('domain.cert.types.' + cert.authority) }}
<span class="text-secondary px-2">({{ $t('domain.cert.valid_for', { days: $tc('day_validity', cert.validity) }) }})</span> <span class="text-secondary px-2">
({{
$t('domain.cert.valid_for', {
days: $tc('day_validity', cert.validity),
})
}})
</span>
</DescriptionRow> </DescriptionRow>
<!-- DOMAIN REGISTRAR --> <!-- DOMAIN REGISTRAR -->
<DescriptionRow v-if="domain.registrar" :term="$t('domain.info.registrar')"> <DescriptionRow
v-if="domain.registrar"
:term="$t('domain.info.registrar')"
>
<template v-if="domain.registrar === 'parent_domain'"> <template v-if="domain.registrar === 'parent_domain'">
{{ $t('domain.see_parent_domain') }}&nbsp;<BLink :href="`#/domains/${domain.topest_parent}/dns`"> {{ $t('domain.see_parent_domain') }}&nbsp;<BLink
:href="`#/domains/${domain.topest_parent}/dns`"
>
{{ domain.topest_parent }} {{ domain.topest_parent }}
</BLink> </BLink>
</template> </template>
@ -59,15 +80,23 @@
<DescriptionRow :term="$t('domain.info.apps_on_domain')"> <DescriptionRow :term="$t('domain.info.apps_on_domain')">
<div> <div>
<BButton-group <BButton-group
v-for="app in domain.apps" :key="app.id" v-for="app in domain.apps"
size="sm" class="mr-2 mb-2" :key="app.id"
size="sm"
class="mr-2 mb-2"
> >
<BButton class="py-0 font-weight-bold" variant="outline-dark" :to="{ name: 'app-info', params: { id: app.id }}"> <BButton
class="py-0 font-weight-bold"
variant="outline-dark"
:to="{ name: 'app-info', params: { id: app.id } }"
>
{{ app.name }} {{ app.name }}
</BButton> </BButton>
<BButton <BButton
variant="outline-dark" class="py-0 px-1" variant="outline-dark"
:href="'https://' + name + app.path" target="_blank" class="py-0 px-1"
:href="'https://' + name + app.path"
target="_blank"
> >
<span class="sr-only">{{ $t('app.visit_app') }}</span> <span class="sr-only">{{ $t('app.visit_app') }}</span>
<YIcon iname="external-link" /> <YIcon iname="external-link" />
@ -87,8 +116,12 @@
<BModal <BModal
v-if="domain" v-if="domain"
id="delete-modal" :title="$t('confirm_delete', { name: this.name })" @ok="deleteDomain" id="delete-modal"
header-bg-variant="warning" body-class="" body-bg-variant="" :title="$t('confirm_delete', { name: this.name })"
@ok="deleteDomain"
header-bg-variant="warning"
body-class=""
body-bg-variant=""
> >
<BFormGroup v-if="isMainDynDomain"> <BFormGroup v-if="isMainDynDomain">
<BFormCheckbox v-model="unsubscribeDomainFromDyndns"> <BFormCheckbox v-model="unsubscribeDomainFromDyndns">
@ -105,52 +138,54 @@ import { mapGetters } from 'vuex'
import api, { objectToParams } from '@/api' import api, { objectToParams } from '@/api'
import { import {
formatFormData, formatFormData,
formatYunoHostConfigPanels formatYunoHostConfigPanels,
} from '@/helpers/yunohostArguments' } from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels.vue' import ConfigPanels from '@/components/ConfigPanels.vue'
import DomainDns from './DomainDns.vue' import DomainDns from './DomainDns.vue'
export default { export default {
name: 'DomainInfo', name: 'DomainInfo',
components: { components: {
ConfigPanels, ConfigPanels,
DomainDns DomainDns,
}, },
props: { props: {
name: { type: String, required: true } name: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [
['GET', { uri: 'domains', storeKey: 'domains' }], ['GET', { uri: 'domains', storeKey: 'domains' }],
['GET', { uri: 'domains', storeKey: 'domains_details', param: this.name }], [
['GET', `domains/${this.name}/config?full`] 'GET',
{ uri: 'domains', storeKey: 'domains_details', param: this.name },
],
['GET', `domains/${this.name}/config?full`],
], ],
config: {}, config: {},
unsubscribeDomainFromDyndns: false unsubscribeDomainFromDyndns: false,
} }
}, },
computed: { computed: {
...mapGetters(['mainDomain']), ...mapGetters(['mainDomain']),
currentTab () { currentTab() {
return this.$route.params.tabId return this.$route.params.tabId
}, },
domain () { domain() {
return this.$store.getters.domain(this.name) return this.$store.getters.domain(this.name)
}, },
parentName () { parentName() {
return this.$store.getters.highestDomainParentName(this.name) return this.$store.getters.highestDomainParentName(this.name)
}, },
cert () { cert() {
const { CA_type: authority, validity } = this.domain.certificate const { CA_type: authority, validity } = this.domain.certificate
const baseInfos = { authority, validity } const baseInfos = { authority, validity }
if (validity <= 0) { if (validity <= 0) {
@ -165,77 +200,98 @@ export default {
return { icon: 'exclamation', variant: 'warning', ...baseInfos } return { icon: 'exclamation', variant: 'warning', ...baseInfos }
}, },
dns () { dns() {
return this.domain.dns return this.domain.dns
}, },
isMainDomain () { isMainDomain() {
if (!this.mainDomain) return if (!this.mainDomain) return
return this.name === this.mainDomain return this.name === this.mainDomain
}, },
isMainDynDomain () { isMainDynDomain() {
return this.domain.registrar === 'yunohost' && this.name.split('.').length === 3 return (
} this.domain.registrar === 'yunohost' &&
this.name.split('.').length === 3
)
},
}, },
methods: { methods: {
onQueriesResponse (domains, domain, config) { onQueriesResponse(domains, domain, config) {
this.config = formatYunoHostConfigPanels(config) this.config = formatYunoHostConfigPanels(config)
}, },
async onConfigSubmit ({ id, form, action, name }) { async onConfigSubmit({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true }) const args = await formatFormData(form, {
removeEmpty: false,
api.put( removeNull: true,
action
? `domain/${this.name}/actions/${action}`
: `domains/${this.name}/config/${id}`,
{ args: objectToParams(args) },
{ key: `domains.${action ? 'action' : 'update'}_config`, id, name: this.name }
).then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
}) })
api
.put(
action
? `domain/${this.name}/actions/${action}`
: `domains/${this.name}/config/${id}`,
{ args: objectToParams(args) },
{
key: `domains.${action ? 'action' : 'update'}_config`,
id,
name: this.name,
},
)
.then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
})
.catch((err) => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find((panel) => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
}, },
async deleteDomain () { async deleteDomain() {
const data = this.isMainDynDomain && !this.unsubscribeDomainFromDyndns const data =
? { ignore_dyndns: 1 } this.isMainDynDomain && !this.unsubscribeDomainFromDyndns
: {} ? { ignore_dyndns: 1 }
: {}
api.delete( api
{ uri: 'domains', param: this.name }, data, { key: 'domains.delete', name: this.name } .delete({ uri: 'domains', param: this.name }, data, {
).then(() => { key: 'domains.delete',
this.$router.push({ name: 'domain-list' }) name: this.name,
}) })
.then(() => {
this.$router.push({ name: 'domain-list' })
})
}, },
async setAsDefaultDomain () { async setAsDefaultDomain() {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_change_maindomain')) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_change_maindomain'),
)
if (!confirmed) return if (!confirmed) return
api.put( api
{ uri: `domains/${this.name}/main`, storeKey: 'main_domain' }, .put(
{}, { uri: `domains/${this.name}/main`, storeKey: 'main_domain' },
{ key: 'domains.set_default', name: this.name } {},
).then(() => { { key: 'domains.set_default', name: this.name },
// FIXME Have to commit by hand here since the response is empty (should return the given name) )
this.$store.commit('UPDATE_MAIN_DOMAIN', this.name) .then(() => {
}) // FIXME Have to commit by hand here since the response is empty (should return the given name)
} this.$store.commit('UPDATE_MAIN_DOMAIN', this.name)
} })
},
},
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.main-domain-badge { .main-domain-badge {
font-size: .75rem; font-size: 0.75rem;
padding-right: .2em; padding-right: 0.2em;
} }
</style> </style>

View file

@ -15,18 +15,27 @@
</BButton> </BButton>
</template> </template>
<RecursiveListGroup :tree="tree" :toggle-text="$t('domain.toggle_subdomains')" class="mb-5"> <RecursiveListGroup
:tree="tree"
:toggle-text="$t('domain.toggle_subdomains')"
class="mb-5"
>
<template #default="{ data, parent }"> <template #default="{ data, parent }">
<div class="w-100 d-flex justify-content-between align-items-center"> <div class="w-100 d-flex justify-content-between align-items-center">
<h5 class="mr-3"> <h5 class="mr-3">
<BLink :to="data.to" class="text-body text-decoration-none"> <BLink :to="data.to" class="text-body text-decoration-none">
<span class="font-weight-bold">{{ data.name.replace(parent ? parent.data.name : null, '') }}</span> <span class="font-weight-bold">
<span v-if="parent" class="text-secondary">{{ parent.data.name }}</span> {{ data.name.replace(parent ? parent.data.name : null, '') }}
</span>
<span v-if="parent" class="text-secondary">
{{ parent.data.name }}
</span>
</BLink> </BLink>
<small <small
v-if="data.name === mainDomain" v-if="data.name === mainDomain"
:title="$t('domain.types.main_domain')" class="ml-1" :title="$t('domain.types.main_domain')"
class="ml-1"
v-b-tooltip.hover v-b-tooltip.hover
> >
<YIcon iname="star" /> <YIcon iname="star" />
@ -43,47 +52,46 @@ import { mapGetters } from 'vuex'
import RecursiveListGroup from '@/components/RecursiveListGroup.vue' import RecursiveListGroup from '@/components/RecursiveListGroup.vue'
export default { export default {
name: 'DomainList', name: 'DomainList',
components: { components: {
RecursiveListGroup RecursiveListGroup,
}, },
data () { data() {
return { return {
queries: [ queries: [['GET', { uri: 'domains', storeKey: 'domains' }]],
['GET', { uri: 'domains', storeKey: 'domains' }]
],
search: '', search: '',
domainsTree: undefined domainsTree: undefined,
} }
}, },
computed: { computed: {
...mapGetters(['domains', 'mainDomain']), ...mapGetters(['domains', 'mainDomain']),
tree () { tree() {
if (!this.domainsTree) return if (!this.domainsTree) return
if (this.search) { if (this.search) {
const search = this.search.toLowerCase() const search = this.search.toLowerCase()
return this.domainsTree.filter(node => node.data.name.includes(search)) return this.domainsTree.filter((node) =>
node.data.name.includes(search),
)
} }
return this.domainsTree return this.domainsTree
}, },
hasFilteredItems () { hasFilteredItems() {
if (!this.tree) return if (!this.tree) return
return this.tree.children || null return this.tree.children || null
} },
}, },
methods: { methods: {
onQueriesResponse () { onQueriesResponse() {
// Add the tree to `data` to make it reactive // Add the tree to `data` to make it reactive
this.domainsTree = this.$store.getters.domainsTree this.domainsTree = this.$store.getters.domainsTree
} },
} },
} }
</script> </script>

View file

@ -1,11 +1,17 @@
<template> <template>
<CardForm <CardForm
:title="$t('group_new')" icon="users" :title="$t('group_new')"
:validation="$v" :server-error="serverError" icon="users"
:validation="$v"
:server-error="serverError"
@submit.prevent="onSubmit" @submit.prevent="onSubmit"
> >
<!-- GROUP NAME --> <!-- GROUP NAME -->
<FormField v-bind="groupname" v-model="form.groupname" :validation="$v.form.groupname" /> <FormField
v-bind="groupname"
v-model="form.groupname"
:validation="$v.form.groupname"
/>
</CardForm> </CardForm>
</template> </template>
@ -18,10 +24,10 @@ import { required, alphalownumdot_ } from '@/helpers/validators'
export default { export default {
name: 'GroupCreate', name: 'GroupCreate',
data () { data() {
return { return {
form: { form: {
groupname: '' groupname: '',
}, },
serverError: '', serverError: '',
groupname: { groupname: {
@ -29,33 +35,35 @@ export default {
description: this.$i18n.t('group_format_name_help'), description: this.$i18n.t('group_format_name_help'),
props: { props: {
id: 'groupname', id: 'groupname',
placeholder: this.$i18n.t('placeholder.groupname') placeholder: this.$i18n.t('placeholder.groupname'),
} },
} },
} }
}, },
validations: { validations: {
form: { form: {
groupname: { required, alphalownumdot_ } groupname: { required, alphalownumdot_ },
} },
}, },
methods: { methods: {
onSubmit () { onSubmit() {
api.post( api
{ uri: 'users/groups', storeKey: 'groups' }, .post({ uri: 'users/groups', storeKey: 'groups' }, this.form, {
this.form, key: 'groups.create',
{ key: 'groups.create', name: this.form.groupname } name: this.form.groupname,
).then(() => { })
this.$router.push({ name: 'group-list' }) .then(() => {
}).catch(err => { this.$router.push({ name: 'group-list' })
if (err.name !== 'APIBadRequestError') throw err })
this.serverError = err.message .catch((err) => {
}) if (err.name !== 'APIBadRequestError') throw err
} this.serverError = err.message
})
},
}, },
mixins: [validationMixin] mixins: [validationMixin],
} }
</script> </script>

View file

@ -16,14 +16,23 @@
<!-- PRIMARY GROUPS CARDS --> <!-- PRIMARY GROUPS CARDS -->
<YCard <YCard
v-for="(group, groupName) in filteredGroups" :key="groupName" collapsable v-for="(group, groupName) in filteredGroups"
:title="group.isSpecial ? $t('group_' + groupName) : `${$t('group')} '${groupName}'`" icon="group" :key="groupName"
collapsable
:title="
group.isSpecial
? $t('group_' + groupName)
: `${$t('group')} '${groupName}'`
"
icon="group"
> >
<template #header-buttons> <template #header-buttons>
<!-- DELETE GROUP --> <!-- DELETE GROUP -->
<BButton <BButton
v-if="!group.isSpecial" @click="deleteGroup(groupName)" v-if="!group.isSpecial"
size="sm" variant="danger" @click="deleteGroup(groupName)"
size="sm"
variant="danger"
> >
<YIcon iname="trash-o" /> {{ $t('delete') }} <YIcon iname="trash-o" /> {{ $t('delete') }}
</BButton> </BButton>
@ -37,23 +46,29 @@
<BCol> <BCol>
<template v-if="group.isSpecial"> <template v-if="group.isSpecial">
<p class="text-primary"> <p class="text-primary">
<YIcon iname="info-circle" /> {{ $t('group_explain_' + groupName) }} <YIcon iname="info-circle" />
{{ $t('group_explain_' + groupName) }}
</p> </p>
<p class="text-primary" v-if="groupName === 'visitors'"> <p class="text-primary" v-if="groupName === 'visitors'">
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em> <em>{{
$t('group_explain_visitors_needed_for_external_client')
}}</em>
</p> </p>
</template> </template>
<template v-if="groupName == 'admins' || !group.isSpecial"> <template v-if="groupName == 'admins' || !group.isSpecial">
<TagsSelectizeItem <TagsSelectizeItem
v-model="group.members" :options="usersOptions" v-model="group.members"
:id="groupName + '-users'" :label="$t('group_add_member')" :options="usersOptions"
tag-icon="user" items-name="users" :id="groupName + '-users'"
:label="$t('group_add_member')"
tag-icon="user"
items-name="users"
@tag-update="onUserChanged({ ...$event, groupName })" @tag-update="onUserChanged({ ...$event, groupName })"
/> />
</template> </template>
</BCol> </BCol>
</BRow> </BRow>
<hr> <hr />
<BRow> <BRow>
<BCol md="3" lg="2"> <BCol md="3" lg="2">
@ -61,9 +76,12 @@
</BCol> </BCol>
<BCol> <BCol>
<TagsSelectizeItem <TagsSelectizeItem
v-model="group.permissions" :options="permissionsOptions" v-model="group.permissions"
:id="groupName + '-perms'" :label="$t('group_add_permission')" :options="permissionsOptions"
tag-icon="key-modern" items-name="permissions" :id="groupName + '-perms'"
:label="$t('group_add_permission')"
tag-icon="key-modern"
items-name="permissions"
@tag-update="onPermissionChanged({ ...$event, groupName })" @tag-update="onPermissionChanged({ ...$event, groupName })"
:disabled-items="group.disabledItems" :disabled-items="group.disabledItems"
/> />
@ -73,8 +91,10 @@
<!-- USER GROUPS CARD --> <!-- USER GROUPS CARD -->
<YCard <YCard
v-if="userGroups" collapsable v-if="userGroups"
:title="$t('group_specific_permissions')" icon="group" collapsable
:title="$t('group_specific_permissions')"
icon="group"
> >
<template v-for="(userName, index) in activeUserGroups"> <template v-for="(userName, index) in activeUserGroups">
<BRow :key="userName"> <BRow :key="userName">
@ -84,20 +104,28 @@
<BCol> <BCol>
<TagsSelectizeItem <TagsSelectizeItem
v-model="userGroups[userName].permissions" :options="permissionsOptions" v-model="userGroups[userName].permissions"
:id="userName + '-perms'" :label="$t('group_add_permission')" :options="permissionsOptions"
tag-icon="key-modern" items-name="permissions" :id="userName + '-perms'"
@tag-update="onPermissionChanged({ ...$event, groupName: userName })" :label="$t('group_add_permission')"
tag-icon="key-modern"
items-name="permissions"
@tag-update="
onPermissionChanged({ ...$event, groupName: userName })
"
/> />
</BCol> </BCol>
</BRow> </BRow>
<hr :key="index"> <hr :key="index" />
</template> </template>
<TagsSelectizeItem <TagsSelectizeItem
v-model="activeUserGroups" :options="usersOptions" v-model="activeUserGroups"
id="user-groups" :label="$t('group_add_member')" :options="usersOptions"
no-tags items-name="users" id="user-groups"
:label="$t('group_add_member')"
no-tags
items-name="users"
@tag-update="onSpecificUserAdded" @tag-update="onSpecificUserAdded"
/> />
</YCard> </YCard>
@ -117,15 +145,21 @@ export default {
name: 'GroupList', name: 'GroupList',
components: { components: {
TagsSelectizeItem TagsSelectizeItem,
}, },
data () { data() {
return { return {
queries: [ queries: [
['GET', { uri: 'users' }], ['GET', { uri: 'users' }],
['GET', { uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' }], [
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }] 'GET',
{
uri: 'users/groups?full&include_primary_groups',
storeKey: 'groups',
},
],
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
], ],
search: '', search: '',
permissions: undefined, permissions: undefined,
@ -133,12 +167,12 @@ export default {
primaryGroups: undefined, primaryGroups: undefined,
userGroups: undefined, userGroups: undefined,
usersOptions: undefined, usersOptions: undefined,
activeUserGroups: undefined activeUserGroups: undefined,
} }
}, },
computed: { computed: {
filteredGroups () { filteredGroups() {
const groups = this.primaryGroups const groups = this.primaryGroups
if (!groups) return if (!groups) return
const search = this.search.toLowerCase() const search = this.search.toLowerCase()
@ -149,14 +183,17 @@ export default {
} }
} }
return isEmptyValue(filtered) ? null : filtered return isEmptyValue(filtered) ? null : filtered
} },
}, },
methods: { methods: {
onQueriesResponse (users, allGroups, permsDict) { onQueriesResponse(users, allGroups, permsDict) {
// Do not use computed properties to get values from the store here to avoid auto // Do not use computed properties to get values from the store here to avoid auto
// updates while modifying values. // updates while modifying values.
const permissions = Object.entries(permsDict).map(([id, value]) => ({ id, ...value })) const permissions = Object.entries(permsDict).map(([id, value]) => ({
id,
...value,
}))
const userNames = users ? Object.keys(users) : [] const userNames = users ? Object.keys(users) : []
const primaryGroups = {} const primaryGroups = {}
const userGroups = {} const userGroups = {}
@ -173,90 +210,124 @@ export default {
continue continue
} }
group.isSpecial = ['visitors', 'all_users', 'admins'].includes(groupName) group.isSpecial = ['visitors', 'all_users', 'admins'].includes(
groupName,
)
if (groupName === 'visitors') { if (groupName === 'visitors') {
// Forbid to add or remove a protected permission on group `visitors` // Forbid to add or remove a protected permission on group `visitors`
group.disabledItems = permissions.filter(({ id }) => { group.disabledItems = permissions
return ['mail.main', 'xmpp.main'].includes(id) || permsDict[id].protected .filter(({ id }) => {
}).map(({ id }) => permsDict[id].label) return (
['mail.main', 'xmpp.main'].includes(id) ||
permsDict[id].protected
)
})
.map(({ id }) => permsDict[id].label)
} }
if (groupName === 'all_users') { if (groupName === 'all_users') {
// Forbid to add ssh and sftp permission on group `all_users` // Forbid to add ssh and sftp permission on group `all_users`
group.disabledItems = permissions.filter(({ id }) => { group.disabledItems = permissions
return ['ssh.main', 'sftp.main'].includes(id) .filter(({ id }) => {
}).map(({ id }) => permsDict[id].label) return ['ssh.main', 'sftp.main'].includes(id)
})
.map(({ id }) => permsDict[id].label)
} }
if (groupName === 'admins') { if (groupName === 'admins') {
// Forbid to add ssh and sftp permission on group `admins` // Forbid to add ssh and sftp permission on group `admins`
group.disabledItems = permissions.filter(({ id }) => { group.disabledItems = permissions
return ['ssh.main', 'sftp.main'].includes(id) .filter(({ id }) => {
}).map(({ id }) => permsDict[id].label) return ['ssh.main', 'sftp.main'].includes(id)
})
.map(({ id }) => permsDict[id].label)
} }
primaryGroups[groupName] = group primaryGroups[groupName] = group
} }
const activeUserGroups = Object.entries(userGroups).filter(([_, group]) => { const activeUserGroups = Object.entries(userGroups)
return group.permissions.length > 0 .filter(([_, group]) => {
}).map(([name]) => name) return group.permissions.length > 0
})
.map(([name]) => name)
Object.assign(this, { Object.assign(this, {
permissions, permissions,
permissionsOptions: permissions.map(perm => perm.label), permissionsOptions: permissions.map((perm) => perm.label),
primaryGroups, primaryGroups,
userGroups: isEmptyValue(userGroups) ? null : userGroups, userGroups: isEmptyValue(userGroups) ? null : userGroups,
usersOptions: userNames, usersOptions: userNames,
activeUserGroups activeUserGroups,
}) })
}, },
async onPermissionChanged ({ option, groupName, action, applyMethod }) { async onPermissionChanged({ option, groupName, action, applyMethod }) {
const permId = this.permissions.find(perm => perm.label === option).id const permId = this.permissions.find((perm) => perm.label === option).id
if (action === 'add' && ['sftp.main', 'ssh.main'].includes(permId)) { if (action === 'add' && ['sftp.main', 'ssh.main'].includes(permId)) {
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_group_add_access_permission', { name: groupName, perm: option }) this.$i18n.t('confirm_group_add_access_permission', {
name: groupName,
perm: option,
}),
) )
if (!confirmed) return if (!confirmed) return
} }
api.put( api
// FIXME hacky way to update the store .put(
{ uri: `users/permissions/${permId}/${action}/${groupName}`, storeKey: 'permissions', groupName, action, permId }, // FIXME hacky way to update the store
{}, {
{ key: 'permissions.' + action, perm: option, name: groupName } uri: `users/permissions/${permId}/${action}/${groupName}`,
).then(() => applyMethod(option)) storeKey: 'permissions',
groupName,
action,
permId,
},
{},
{ key: 'permissions.' + action, perm: option, name: groupName },
)
.then(() => applyMethod(option))
}, },
onUserChanged ({ option, groupName, action, applyMethod }) { onUserChanged({ option, groupName, action, applyMethod }) {
api.put( api
{ uri: `users/groups/${groupName}/${action}/${option}`, storeKey: 'groups', groupName }, .put(
{}, {
{ key: 'groups.' + action, user: option, name: groupName } uri: `users/groups/${groupName}/${action}/${option}`,
).then(() => applyMethod(option)) storeKey: 'groups',
groupName,
},
{},
{ key: 'groups.' + action, user: option, name: groupName },
)
.then(() => applyMethod(option))
}, },
onSpecificUserAdded ({ option: userName, action, applyMethod }) { onSpecificUserAdded({ option: userName, action, applyMethod }) {
if (action === 'add') { if (action === 'add') {
this.userGroups[userName].permissions = [] this.userGroups[userName].permissions = []
applyMethod(userName) applyMethod(userName)
} }
}, },
async deleteGroup (groupName) { async deleteGroup(groupName) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: groupName })) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_delete', { name: groupName }),
)
if (!confirmed) return if (!confirmed) return
api.delete( api
{ uri: 'users/groups', param: groupName, storeKey: 'groups' }, .delete(
{}, { uri: 'users/groups', param: groupName, storeKey: 'groups' },
{ key: 'groups.delete', name: groupName } {},
).then(() => { { key: 'groups.delete', name: groupName },
Vue.delete(this.primaryGroups, groupName) )
}) .then(() => {
} Vue.delete(this.primaryGroups, groupName)
} })
},
},
} }
</script> </script>

View file

@ -1,7 +1,9 @@
<template> <template>
<ViewBase <ViewBase
:queries="queries" @queries-response="onQueriesResponse" :queries="queries"
ref="view" skeleton="CardInfoSkeleton" @queries-response="onQueriesResponse"
ref="view"
skeleton="CardInfoSkeleton"
> >
<!-- INFO CARD --> <!-- INFO CARD -->
<YCard :title="name" icon="info-circle" button-unbreak="sm"> <YCard :title="name" icon="info-circle" button-unbreak="sm">
@ -13,7 +15,11 @@
</BButton> </BButton>
<!-- STOP SERVICE --> <!-- STOP SERVICE -->
<BButton v-if="!isCritical" @click="updateService('stop')" variant="danger"> <BButton
v-if="!isCritical"
@click="updateService('stop')"
variant="danger"
>
<YIcon iname="warning" /> {{ $t('stop') }} <YIcon iname="warning" /> {{ $t('stop') }}
</BButton> </BButton>
</template> </template>
@ -25,11 +31,15 @@
</template> </template>
<BRow <BRow
v-for="(value, key) in infos" :key="key" v-for="(value, key) in infos"
no-gutters class="row-line" :key="key"
no-gutters
class="row-line"
> >
<BCol md="3" xl="2"> <BCol md="3" xl="2">
<strong>{{ $t(key === 'start_on_boot' ? 'service_' + key : key) }}</strong> <strong>
{{ $t(key === 'start_on_boot' ? 'service_' + key : key) }}
</strong>
</BCol> </BCol>
<BCol> <BCol>
<template v-if="key === 'status'"> <template v-if="key === 'status'">
@ -37,10 +47,13 @@
<YIcon :iname="value === 'running' ? 'check-circle' : 'times'" /> <YIcon :iname="value === 'running' ? 'check-circle' : 'times'" />
{{ $t(value) }} {{ $t(value) }}
</span> </span>
{{ $t('since') }} {{ distanceToNow(uptime ) }} {{ $t('since') }} {{ distanceToNow(uptime) }}
</template> </template>
<span v-else-if="key === 'start_on_boot'" :class="value === 'enabled' ? 'text-success' : 'text-danger'"> <span
v-else-if="key === 'start_on_boot'"
:class="value === 'enabled' ? 'text-success' : 'text-danger'"
>
{{ $t(value) }} {{ $t(value) }}
</span> </span>
@ -76,14 +89,14 @@ export default {
name: 'ServiceInfo', name: 'ServiceInfo',
props: { props: {
name: { type: String, required: true } name: { type: String, required: true },
}, },
data () { data() {
return { return {
queries: [ queries: [
['GET', 'services/' + this.name], ['GET', 'services/' + this.name],
['GET', `services/${this.name}/log?number=50`] ['GET', `services/${this.name}/log?number=50`],
], ],
// Service data // Service data
infos: undefined, infos: undefined,
@ -91,62 +104,71 @@ export default {
isCritical: undefined, isCritical: undefined,
logs: undefined, logs: undefined,
// Modal action // Modal action
action: undefined action: undefined,
} }
}, },
methods: { methods: {
onQueriesResponse ( onQueriesResponse(
// eslint-disable-next-line // eslint-disable-next-line
{ status, description, start_on_boot, last_state_change, configuration }, { status, description, start_on_boot, last_state_change, configuration },
logs logs,
) { ) {
this.isCritical = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(this.name) this.isCritical = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(
this.name,
)
// eslint-disable-next-line // eslint-disable-next-line
this.uptime = last_state_change === 'unknown' ? 0 : last_state_change this.uptime = last_state_change === 'unknown' ? 0 : last_state_change
this.infos = { description, status, start_on_boot, configuration } this.infos = { description, status, start_on_boot, configuration }
this.logs = Object.keys(logs).sort((prev, curr) => { this.logs = Object.keys(logs)
if (prev === 'journalctl') return -1 .sort((prev, curr) => {
else if (curr === 'journalctl') return 1 if (prev === 'journalctl') return -1
else if (prev < curr) return -1 else if (curr === 'journalctl') return 1
else return 1 else if (prev < curr) return -1
}).map(filename => ({ content: logs[filename].join('\n'), filename })) else return 1
})
.map((filename) => ({ content: logs[filename].join('\n'), filename }))
}, },
async updateService (action) { async updateService(action) {
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_service_' + action, { name: this.name }) this.$i18n.t('confirm_service_' + action, { name: this.name }),
) )
if (!confirmed) return if (!confirmed) return
api.put( api
`services/${this.name}/${action}`, .put(
{}, `services/${this.name}/${action}`,
{ key: 'services.' + action, name: this.name } {},
).then(this.$refs.view.fetchQueries) { key: 'services.' + action, name: this.name },
)
.then(this.$refs.view.fetchQueries)
}, },
shareLogs () { shareLogs() {
const logs = this.logs.map(({ filename, content }) => { const logs = this.logs
return `LOGFILE: ${filename}\n${content}` .map(({ filename, content }) => {
}).join('\n\n') return `LOGFILE: ${filename}\n${content}`
})
.join('\n\n')
fetch('https://paste.yunohost.org/documents', { fetch('https://paste.yunohost.org/documents', {
method: 'POST', method: 'POST',
body: logs body: logs,
}).then(response => {
if (response.ok) return response.json()
// FIXME flash error
/* eslint-disable-next-line */
else console.log('error', response)
}).then(({ key }) => {
window.open('https://paste.yunohost.org/' + key, '_blank')
}) })
.then((response) => {
if (response.ok) return response.json()
// FIXME flash error
/* eslint-disable-next-line */ else console.log('error', response)
})
.then(({ key }) => {
window.open('https://paste.yunohost.org/' + key, '_blank')
})
}, },
distanceToNow distanceToNow,
} },
} }
</script> </script>

View file

@ -10,8 +10,14 @@
> >
<BListGroup> <BListGroup>
<BListGroupItem <BListGroupItem
v-for="{ name, description, status, last_state_change } in filteredServices" :key="name" v-for="{
:to="{ name: 'service-info', params: { name }}" name,
description,
status,
last_state_change,
} in filteredServices"
:key="name"
:to="{ name: 'service-info', params: { name } }"
class="d-flex justify-content-between align-items-center pr-0" class="d-flex justify-content-between align-items-center pr-0"
> >
<div> <div>
@ -20,7 +26,9 @@
<small class="text-secondary">{{ description }}</small> <small class="text-secondary">{{ description }}</small>
</h5> </h5>
<p class="m-0"> <p class="m-0">
<span :class="status === 'running' ? 'text-success' : 'text-danger'"> <span
:class="status === 'running' ? 'text-success' : 'text-danger'"
>
<YIcon :iname="status === 'running' ? 'check-circle' : 'times'" /> <YIcon :iname="status === 'running' ? 'check-circle' : 'times'" />
{{ $t(status) }} {{ $t(status) }}
</span> </span>
@ -40,48 +48,48 @@ import { distanceToNow } from '@/helpers/filters/date'
export default { export default {
name: 'ServiceList', name: 'ServiceList',
data () { data() {
return { return {
queries: [ queries: [['GET', 'services']],
['GET', 'services']
],
search: '', search: '',
services: undefined services: undefined,
} }
}, },
computed: { computed: {
filteredServices () { filteredServices() {
if (!this.services) return if (!this.services) return
const search = this.search.toLowerCase() const search = this.search.toLowerCase()
const services = this.services.filter(({ name }) => { const services = this.services.filter(({ name }) => {
return name.toLowerCase().includes(search) return name.toLowerCase().includes(search)
}) })
return services.length ? services : null return services.length ? services : null
} },
}, },
methods: { methods: {
onQueriesResponse (services) { onQueriesResponse(services) {
this.services = Object.keys(services).sort().map(name => { this.services = Object.keys(services)
const service = services[name] .sort()
if (service.last_state_change === 'unknown') { .map((name) => {
service.last_state_change = 0 const service = services[name]
} if (service.last_state_change === 'unknown') {
return { ...service, name } service.last_state_change = 0
}) }
return { ...service, name }
})
}, },
distanceToNow distanceToNow,
} },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
h5 small { h5 small {
display: block; display: block;
margin-top: .25rem; margin-top: 0.25rem;
} }
} }
</style> </style>

View file

@ -1,17 +1,16 @@
<template> <template>
<ViewBase <ViewBase
:queries="queries" @queries-response="onQueriesResponse" :queries="queries"
ref="view" skeleton="CardFormSkeleton" @queries-response="onQueriesResponse"
ref="view"
skeleton="CardFormSkeleton"
> >
<!-- PORTS --> <!-- PORTS -->
<YCard :title="$t('ports')" icon="shield"> <YCard :title="$t('ports')" icon="shield">
<div v-for="(items, protocol) in protocols" :key="protocol"> <div v-for="(items, protocol) in protocols" :key="protocol">
<h5>{{ $t(protocol) }}</h5> <h5>{{ $t(protocol) }}</h5>
<BTable <BTable :fields="fields" :items="items" small striped responsive>
:fields="fields" :items="items"
small striped responsive
>
<!-- PORT CELL --> <!-- PORT CELL -->
<template #cell(port)="data"> <template #cell(port)="data">
{{ data.value }} {{ data.value }}
@ -24,9 +23,21 @@
class="on-off-switch" class="on-off-switch"
v-model="data.value" v-model="data.value"
switch switch
@change="onTablePortToggling(data.item.port, protocol, data.field.key, data.index, $event)" @change="
onTablePortToggling(
data.item.port,
protocol,
data.field.key,
data.index,
$event,
)
"
> >
<span :class="'btn btn-sm py-0 btn-' + (data.value ? 'danger' : 'success')"> <span
:class="
'btn btn-sm py-0 btn-' + (data.value ? 'danger' : 'success')
"
>
{{ $t(data.value ? 'close' : 'open') }} {{ $t(data.value ? 'close' : 'open') }}
</span> </span>
</BFormCheckbox> </BFormCheckbox>
@ -43,10 +54,13 @@
<!-- OPERATIONS --> <!-- OPERATIONS -->
<CardForm <CardForm
:title="$t('operations')" icon="cogs" :title="$t('operations')"
:validation="$v" :server-error="serverError" icon="cogs"
:validation="$v"
:server-error="serverError"
@submit.prevent="onFormPortToggling" @submit.prevent="onFormPortToggling"
inline form-classes="d-flex justify-content-between align-items-start" inline
form-classes="d-flex justify-content-between align-items-start"
> >
<BInputGroup :prepend="$t('action')"> <BInputGroup :prepend="$t('action')">
<BFormSelect v-model="form.action" :options="actionChoices" /> <BFormSelect v-model="form.action" :options="actionChoices" />
@ -55,32 +69,49 @@
<FormField :validation="$v.form.port"> <FormField :validation="$v.form.port">
<BInputGroup :prepend="$t('port')"> <BInputGroup :prepend="$t('port')">
<InputItem <InputItem
id="input-port" placeholder="0" type="number" id="input-port"
placeholder="0"
type="number"
v-model="form.port" v-model="form.port"
/> />
</BInputGroup> </BInputGroup>
</FormField> </FormField>
<BInputGroup :prepend="$t('connection')"> <BInputGroup :prepend="$t('connection')">
<BFormSelect v-model="form.connection" :options="connectionChoices" id="input-connection" /> <BFormSelect
v-model="form.connection"
:options="connectionChoices"
id="input-connection"
/>
</BInputGroup> </BInputGroup>
<BInputGroup :prepend="$t('protocol')"> <BInputGroup :prepend="$t('protocol')">
<BFormSelect v-model="form.protocol" :options="protocolChoices" id="input-protocol" /> <BFormSelect
v-model="form.protocol"
:options="protocolChoices"
id="input-protocol"
/>
</BInputGroup> </BInputGroup>
</CardForm> </CardForm>
<!-- UPnP --> <!-- UPnP -->
<YCard :title="$t('upnp')" icon="exchange" :body-text-variant="upnpEnabled ? 'success' : 'danger'"> <YCard
{{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled' ) }} :title="$t('upnp')"
icon="exchange"
:body-text-variant="upnpEnabled ? 'success' : 'danger'"
>
{{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled') }}
<BFormInvalidFeedback :state="upnpError !== '' ? false : null"> <BFormInvalidFeedback :state="upnpError !== '' ? false : null">
{{ upnpError }} {{ upnpError }}
</BFormInvalidFeedback> </BFormInvalidFeedback>
<template #buttons> <template #buttons>
<BButton @click="toggleUpnp" :variant="!upnpEnabled ? 'success' : 'danger'"> <BButton
{{ $t(!upnpEnabled ? 'enable' : 'disable' ) }} @click="toggleUpnp"
:variant="!upnpEnabled ? 'success' : 'danger'"
>
{{ $t(!upnpEnabled ? 'enable' : 'disable') }}
</BButton> </BButton>
</template> </template>
</YCard> </YCard>
@ -96,11 +127,9 @@ import { required, integer, between } from '@/helpers/validators'
export default { export default {
name: 'ToolFirewall', name: 'ToolFirewall',
data () { data() {
return { return {
queries: [ queries: [['GET', '/firewall?raw']],
['GET', '/firewall?raw']
],
serverError: '', serverError: '',
// Ports tables data // Ports tables data
@ -108,7 +137,7 @@ export default {
{ key: 'port', label: this.$i18n.t('port') }, { key: 'port', label: this.$i18n.t('port') },
{ key: 'ipv4', label: this.$i18n.t('ipv4') }, { key: 'ipv4', label: this.$i18n.t('ipv4') },
{ key: 'ipv6', label: this.$i18n.t('ipv6') }, { key: 'ipv6', label: this.$i18n.t('ipv6') },
{ key: 'uPnP', label: this.$i18n.t('upnp') } { key: 'uPnP', label: this.$i18n.t('upnp') },
], ],
protocols: undefined, protocols: undefined,
portToToggle: undefined, portToToggle: undefined,
@ -116,50 +145,53 @@ export default {
// Ports form data // Ports form data
actionChoices: [ actionChoices: [
{ value: 'allow', text: this.$i18n.t('open') }, { value: 'allow', text: this.$i18n.t('open') },
{ value: 'disallow', text: this.$i18n.t('close') } { value: 'disallow', text: this.$i18n.t('close') },
], ],
connectionChoices: [ connectionChoices: [
{ value: 'ipv4', text: this.$i18n.t('ipv4') }, { value: 'ipv4', text: this.$i18n.t('ipv4') },
{ value: 'ipv6', text: this.$i18n.t('ipv6') } { value: 'ipv6', text: this.$i18n.t('ipv6') },
], ],
protocolChoices: [ protocolChoices: [
{ value: 'TCP', text: this.$i18n.t('tcp') }, { value: 'TCP', text: this.$i18n.t('tcp') },
{ value: 'UDP', text: this.$i18n.t('udp') }, { value: 'UDP', text: this.$i18n.t('udp') },
{ value: 'Both', text: this.$i18n.t('both') } { value: 'Both', text: this.$i18n.t('both') },
], ],
form: { form: {
action: 'allow', action: 'allow',
port: undefined, port: undefined,
connection: 'ipv4', connection: 'ipv4',
protocol: 'TCP' protocol: 'TCP',
}, },
// uPnP // uPnP
upnpEnabled: undefined, upnpEnabled: undefined,
upnpError: '' upnpError: '',
} }
}, },
validations: { validations: {
form: { form: {
port: { number: required, integer, between: between(0, 65535) } port: { number: required, integer, between: between(0, 65535) },
} },
}, },
methods: { methods: {
onQueriesResponse (data) { onQueriesResponse(data) {
const ports = Object.values(data).reduce((ports, protocols) => { const ports = Object.values(data).reduce(
for (const type of ['TCP', 'UDP']) { (ports, protocols) => {
for (const port of protocols[type]) { for (const type of ['TCP', 'UDP']) {
ports[type].add(port) for (const port of protocols[type]) {
ports[type].add(port)
}
} }
} return ports
return ports },
}, { TCP: new Set(), UDP: new Set() }) { TCP: new Set(), UDP: new Set() },
)
const tables = { const tables = {
TCP: [], TCP: [],
UDP: [] UDP: [],
} }
for (const protocol of ['TCP', 'UDP']) { for (const protocol of ['TCP', 'UDP']) {
for (const port of ports[protocol]) { for (const port of ports[protocol]) {
@ -169,89 +201,111 @@ export default {
} }
tables[protocol].push(row) tables[protocol].push(row)
} }
tables[protocol].sort((a, b) => a.port < b.port ? -1 : 1) tables[protocol].sort((a, b) => (a.port < b.port ? -1 : 1))
} }
this.protocols = tables this.protocols = tables
this.upnpEnabled = data.uPnP.enabled this.upnpEnabled = data.uPnP.enabled
}, },
async togglePort ({ action, port, protocol, connection }) { async togglePort({ action, port, protocol, connection }) {
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_firewall_' + action, { port, protocol, connection }) this.$i18n.t('confirm_firewall_' + action, {
port,
protocol,
connection,
}),
) )
if (!confirmed) { if (!confirmed) {
return Promise.resolve(confirmed) return Promise.resolve(confirmed)
} }
const actionTrad = this.$i18n.t({ allow: 'open', disallow: 'close' }[action]) const actionTrad = this.$i18n.t(
return api.put( { allow: 'open', disallow: 'close' }[action],
`firewall/${protocol}/${action}/${port}?${connection}_only`, )
{}, return api
{ key: 'firewall.ports', protocol, action: actionTrad, port, connection }, .put(
{ wait: false } `firewall/${protocol}/${action}/${port}?${connection}_only`,
).then(() => confirmed) {},
{
key: 'firewall.ports',
protocol,
action: actionTrad,
port,
connection,
},
{ wait: false },
)
.then(() => confirmed)
}, },
async toggleUpnp (value) { async toggleUpnp(value) {
const action = this.upnpEnabled ? 'disable' : 'enable' const action = this.upnpEnabled ? 'disable' : 'enable'
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_upnp_' + action)) const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_upnp_' + action),
)
if (!confirmed) return if (!confirmed) return
api.put( api
'firewall/upnp/' + action, .put(
{}, 'firewall/upnp/' + action,
{ key: 'firewall.upnp', action: this.$i18n.t(action) } {},
).then(() => { { key: 'firewall.upnp', action: this.$i18n.t(action) },
// FIXME Couldn't test when it works. )
this.$refs.view.fetchQueries() .then(() => {
}).catch(err => { // FIXME Couldn't test when it works.
if (err.name !== 'APIBadRequestError') throw err this.$refs.view.fetchQueries()
this.upnpError = err.message })
}) .catch((err) => {
if (err.name !== 'APIBadRequestError') throw err
this.upnpError = err.message
})
}, },
onTablePortToggling (port, protocol, connection, index, value) { onTablePortToggling(port, protocol, connection, index, value) {
this.$set(this.protocols[protocol][index], connection, value) this.$set(this.protocols[protocol][index], connection, value)
const action = value ? 'allow' : 'disallow' const action = value ? 'allow' : 'disallow'
this.togglePort({ action, port, protocol, connection }).then(toggled => { this.togglePort({ action, port, protocol, connection }).then(
// Revert change on cancel (toggled) => {
if (!toggled) { // Revert change on cancel
this.$set(this.protocols[protocol][index], connection, !value) if (!toggled) {
} this.$set(this.protocols[protocol][index], connection, !value)
}) }
},
)
}, },
onFormPortToggling (e) { onFormPortToggling(e) {
this.togglePort(this.form).then(toggled => { this.togglePort(this.form).then((toggled) => {
if (toggled) this.$refs.view.fetchQueries() if (toggled) this.$refs.view.fetchQueries()
}) })
} },
}, },
mixins: [validationMixin] mixins: [validationMixin],
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
::v-deep .on-off-switch { ::v-deep .on-off-switch {
.custom-control-input { .custom-control-input {
&:checked ~ .custom-control-label::before { &:checked ~ .custom-control-label::before {
border-color: $success; border-color: $success;
background-color: $success; background-color: $success;
}
&:not(:checked) ~ .custom-control-label {
&::before {
border-color: $danger;
background-color: $danger;
} }
&:not(:checked) ~ .custom-control-label { &::after {
&::before { background-color: $white;
border-color: $danger;
background-color: $danger;
}
&::after {
background-color: $white;
}
} }
}
} }
input:focus ~ .custom-control-label, &:hover { input:focus ~ .custom-control-label,
&:hover {
span { span {
visibility: visible; visibility: visible;
} }

Some files were not shown because too many files have changed in this diff Show more