mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
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:
commit
d0cca4d423
116 changed files with 5775 additions and 5466 deletions
6
.github/workflows/eslint.yml
vendored
6
.github/workflows/eslint.yml
vendored
|
@ -11,7 +11,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install npm dependencies
|
||||
run: cd app && npm ci
|
||||
- name: Install yarn dependencies
|
||||
run: cd app && yarn install --frozen-lockfile
|
||||
- name: Run linter
|
||||
run: cd app && npm run lint
|
||||
run: cd app && yarn lint
|
||||
|
|
|
@ -2,37 +2,17 @@ module.exports = {
|
|||
root: true,
|
||||
env: {
|
||||
es2021: true,
|
||||
node: true
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/strongly-recommended',
|
||||
'eslint:recommended',
|
||||
'standard'
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
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': [
|
||||
'warn',
|
||||
{ varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }
|
||||
{ varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
|
||||
],
|
||||
quotes: 'warn',
|
||||
'no-multiple-empty-lines': [
|
||||
'error',
|
||||
{
|
||||
max: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
1
app/.prettierignore
Normal file
1
app/.prettierignore
Normal file
|
@ -0,0 +1 @@
|
|||
dist/
|
19
app/.prettierrc
Normal file
19
app/.prettierrc
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "**/*.json",
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "./*.json",
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,23 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<body>
|
||||
<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
2703
app/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -7,8 +7,10 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint --ext .js,.vue src",
|
||||
"lint-fix": "lint --fix"
|
||||
"lint:js": "eslint --ext \".ts,.vue,.cjs,.js\" --ignore-path ../.gitignore .",
|
||||
"lint:prettier": "prettier --check .",
|
||||
"lint": "yarn lint:js && yarn lint:prettier",
|
||||
"lintfix": "prettier --write --list-different . && yarn lint:js --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/fira-code": "^4.5.13",
|
||||
|
@ -28,11 +30,13 @@
|
|||
"@vitejs/plugin-vue2": "^2.2.0",
|
||||
"bootstrap": "^4.6.0",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-vue": "^9.10.0",
|
||||
"popper.js": "^1.16.0",
|
||||
"portal-vue": "^2.1.7",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.60.0",
|
||||
"standard": "^17.0.0",
|
||||
"vite": "^4.2.1"
|
||||
},
|
||||
"browserslist": [
|
||||
|
|
|
@ -4,23 +4,22 @@
|
|||
<header>
|
||||
<BNavbar>
|
||||
<BNavbarBrand
|
||||
:to="{ name: 'home' }" :disabled="waiting"
|
||||
exact exact-active-class="active"
|
||||
:to="{ name: 'home' }"
|
||||
:disabled="waiting"
|
||||
exact
|
||||
exact-active-class="active"
|
||||
>
|
||||
<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 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>
|
||||
</BNavbarBrand>
|
||||
|
||||
<BNavbarNav class="ml-auto">
|
||||
<li class="nav-item">
|
||||
<BButton
|
||||
:href="ssoLink"
|
||||
variant="primary" size="sm" block
|
||||
>
|
||||
<BButton :href="ssoLink" variant="primary" size="sm" block>
|
||||
{{ $t('user_interface_link') }} <YIcon iname="user" />
|
||||
</BButton>
|
||||
</li>
|
||||
|
@ -28,7 +27,9 @@
|
|||
<li class="nav-item" v-show="connected">
|
||||
<BButton
|
||||
@click.prevent="logout"
|
||||
variant="outline-dark" block size="sm"
|
||||
variant="outline-dark"
|
||||
block
|
||||
size="sm"
|
||||
>
|
||||
{{ $t('logout') }} <YIcon iname="sign-out" />
|
||||
</BButton>
|
||||
|
@ -58,18 +59,32 @@
|
|||
<footer class="py-3 mt-auto">
|
||||
<nav>
|
||||
<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') }}
|
||||
</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') }}
|
||||
</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') }}
|
||||
</BNavItem>
|
||||
|
||||
<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)" />
|
||||
</BNavText>
|
||||
|
@ -89,7 +104,7 @@ export default {
|
|||
|
||||
components: {
|
||||
HistoryConsole,
|
||||
ViewLockOverlay
|
||||
ViewLockOverlay,
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
@ -101,29 +116,31 @@ export default {
|
|||
'transitionName',
|
||||
'waiting',
|
||||
'theme',
|
||||
'ssoLink'
|
||||
])
|
||||
'ssoLink',
|
||||
]),
|
||||
},
|
||||
|
||||
methods: {
|
||||
async logout () {
|
||||
async logout() {
|
||||
this.$store.dispatch('LOGOUT')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// This hook is only triggered at page first load
|
||||
created () {
|
||||
created() {
|
||||
this.$store.dispatch('ON_APP_CREATED')
|
||||
},
|
||||
|
||||
mounted () {
|
||||
mounted() {
|
||||
// Unlock copypasta on log view
|
||||
const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp']
|
||||
let copypastastep = 0
|
||||
document.addEventListener('keydown', ({ key }) => {
|
||||
if (key === copypastaCode[copypastastep++]) {
|
||||
if (copypastastep === copypastaCode.length) {
|
||||
document.getElementsByClassName('unselectable').forEach((element) => element.classList.remove('unselectable'))
|
||||
document
|
||||
.getElementsByClassName('unselectable')
|
||||
.forEach((element) => element.classList.remove('unselectable'))
|
||||
copypastastep = 0
|
||||
}
|
||||
} else {
|
||||
|
@ -132,7 +149,18 @@ export default {
|
|||
})
|
||||
|
||||
// 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
|
||||
document.addEventListener('keydown', ({ key }) => {
|
||||
if (key === konamiCode[konamistep++]) {
|
||||
|
@ -157,7 +185,7 @@ export default {
|
|||
}
|
||||
|
||||
document.documentElement.setAttribute('dark-theme', this.theme) // updates the data-theme attribute
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -177,14 +205,14 @@ header {
|
|||
padding: 1rem 0;
|
||||
|
||||
img {
|
||||
width: 70px;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
flex-direction: column;
|
||||
|
||||
li {
|
||||
margin: .2rem 0;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -195,15 +223,17 @@ main {
|
|||
|
||||
// Routes transition
|
||||
.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;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
transform: translate(100vw, 0);
|
||||
}
|
||||
.slide-left-leave-active, .slide-right-enter {
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
|
@ -229,7 +259,7 @@ footer {
|
|||
|
||||
.nav-item {
|
||||
& + .nav-item a::before {
|
||||
content: "•";
|
||||
content: '•';
|
||||
width: 1rem;
|
||||
display: inline-block;
|
||||
margin-left: -1.15rem;
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
import store from '@/store'
|
||||
import { openWebSocket, getResponseData, handleError } from './handlers'
|
||||
|
||||
|
||||
/**
|
||||
* 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"`).
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* 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 {Object|null} 2 - "data"
|
||||
* @property {Options} 3 - "options"
|
||||
*/
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @return {URLSearchParams}
|
||||
*/
|
||||
export function objectToParams (obj, { addLocale = false } = {}, formData = false) {
|
||||
const urlParams = (formData) ? new FormData() : new URLSearchParams()
|
||||
export function objectToParams(
|
||||
obj,
|
||||
{ addLocale = false } = {},
|
||||
formData = false,
|
||||
) {
|
||||
const urlParams = formData ? new FormData() : new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => urlParams.append(key, v))
|
||||
value.forEach((v) => urlParams.append(key, v))
|
||||
} else {
|
||||
urlParams.append(key, value)
|
||||
}
|
||||
|
@ -53,7 +54,6 @@ export function objectToParams (obj, { addLocale = false } = {}, formData = fals
|
|||
return urlParams
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
options: {
|
||||
credentials: 'include',
|
||||
|
@ -64,11 +64,10 @@ export default {
|
|||
// Auto header is :
|
||||
// "Accept": "*/*",
|
||||
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 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 }]
|
||||
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
|
||||
*/
|
||||
async fetch (
|
||||
async fetch(
|
||||
method,
|
||||
uri,
|
||||
data = {},
|
||||
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.
|
||||
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) {
|
||||
await openWebSocket(request)
|
||||
|
@ -96,17 +102,22 @@ export default {
|
|||
if (method === 'GET') {
|
||||
uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`
|
||||
} 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 responseData = await getResponseData(response)
|
||||
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.
|
||||
* 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}
|
||||
* @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 = []
|
||||
if (wait) store.commit('SET_WAITING', true)
|
||||
try {
|
||||
for (const [method, uri, data, humanKey, options = {}] of queries) {
|
||||
if (wait) options.wait = false
|
||||
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 {
|
||||
// Stop waiting even if there is an error.
|
||||
|
@ -134,7 +147,6 @@ export default {
|
|||
return results
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 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 }`)
|
||||
* @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 }
|
||||
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 })
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Api post helper function.
|
||||
*
|
||||
|
@ -158,12 +170,12 @@ export default {
|
|||
* @param {Options} [options={}] - options to apply to the call
|
||||
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
|
||||
*/
|
||||
post (uri, data = {}, humanKey = null, options = {}) {
|
||||
if (typeof uri === 'string') return this.fetch('POST', uri, data, humanKey, options)
|
||||
post(uri, data = {}, humanKey = null, options = {}) {
|
||||
if (typeof uri === 'string')
|
||||
return this.fetch('POST', uri, data, humanKey, options)
|
||||
return store.dispatch('POST', { ...uri, data, humanKey, options })
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Api put helper function.
|
||||
*
|
||||
|
@ -172,12 +184,12 @@ export default {
|
|||
* @param {Options} [options={}] - options to apply to the call
|
||||
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
|
||||
*/
|
||||
put (uri, data = {}, humanKey = null, options = {}) {
|
||||
if (typeof uri === 'string') return this.fetch('PUT', uri, data, humanKey, options)
|
||||
put(uri, data = {}, humanKey = null, options = {}) {
|
||||
if (typeof uri === 'string')
|
||||
return this.fetch('PUT', uri, data, humanKey, options)
|
||||
return store.dispatch('PUT', { ...uri, data, humanKey, options })
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 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 }`)
|
||||
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
|
||||
*/
|
||||
delete (uri, data = {}, humanKey = null, options = {}) {
|
||||
if (typeof uri === 'string') return this.fetch('DELETE', uri, data, humanKey, options)
|
||||
delete(uri, data = {}, humanKey = null, options = {}) {
|
||||
if (typeof uri === 'string')
|
||||
return this.fetch('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.
|
||||
* @return {Promise<undefined|Error>}
|
||||
*/
|
||||
tryToReconnect ({ attemps = 5, delay = 2000, initialDelay = 0 } = {}) {
|
||||
tryToReconnect({ attemps = 5, delay = 2000, initialDelay = 0 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const api = this
|
||||
|
||||
function reconnect (n) {
|
||||
api.get('logout', {}, { key: 'reconnecting' }).then(resolve).catch(err => {
|
||||
if (err.name === 'APIUnauthorizedError') {
|
||||
resolve()
|
||||
} else if (n < 1) {
|
||||
reject(err)
|
||||
} else {
|
||||
setTimeout(() => reconnect(n - 1), delay)
|
||||
}
|
||||
})
|
||||
function reconnect(n) {
|
||||
api
|
||||
.get('logout', {}, { key: 'reconnecting' })
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
if (err.name === 'APIUnauthorizedError') {
|
||||
resolve()
|
||||
} else if (n < 1) {
|
||||
reject(err)
|
||||
} else {
|
||||
setTimeout(() => reconnect(n - 1), delay)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (initialDelay > 0) setTimeout(() => reconnect(attemps), initialDelay)
|
||||
else reconnect(attemps)
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -5,10 +5,13 @@
|
|||
|
||||
import i18n from '@/i18n'
|
||||
|
||||
|
||||
class APIError extends Error {
|
||||
constructor (request, { url, status, statusText }, { error }) {
|
||||
super(error ? error.replaceAll('\n', '<br>') : i18n.t('error_server_unexpected'))
|
||||
constructor(request, { url, status, statusText }, { error }) {
|
||||
super(
|
||||
error
|
||||
? error.replaceAll('\n', '<br>')
|
||||
: i18n.t('error_server_unexpected'),
|
||||
)
|
||||
const urlObj = new URL(url)
|
||||
this.name = 'APIError'
|
||||
this.code = status
|
||||
|
@ -18,7 +21,7 @@ class APIError extends Error {
|
|||
this.path = urlObj.pathname + urlObj.search
|
||||
}
|
||||
|
||||
log () {
|
||||
log() {
|
||||
/* eslint-disable-next-line */
|
||||
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)
|
||||
class APIErrorLog extends APIError {
|
||||
constructor (method, response, errorData) {
|
||||
constructor(method, response, errorData) {
|
||||
super(method, response, errorData)
|
||||
this.logRef = errorData.log_ref
|
||||
this.name = 'APIErrorLog'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 0 — (means "the connexion has been closed" apparently)
|
||||
class APIConnexionError extends APIError {
|
||||
constructor (method, response) {
|
||||
constructor(method, response) {
|
||||
super(method, response, { error: i18n.t('error_connection_interrupted') })
|
||||
this.name = 'APIConnexionError'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 400 — Bad Request
|
||||
class APIBadRequestError extends APIError {
|
||||
constructor (method, response, errorData) {
|
||||
constructor(method, response, errorData) {
|
||||
super(method, response, errorData)
|
||||
this.name = 'APIBadRequestError'
|
||||
this.key = errorData.error_key
|
||||
|
@ -53,45 +54,40 @@ class APIBadRequestError extends APIError {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// 401 — Unauthorized
|
||||
class APIUnauthorizedError extends APIError {
|
||||
constructor (method, response, errorData) {
|
||||
constructor(method, response, errorData) {
|
||||
super(method, response, { error: i18n.t('unauthorized') })
|
||||
this.name = 'APIUnauthorizedError'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 404 — Not Found
|
||||
class APINotFoundError extends APIError {
|
||||
constructor (method, response, errorData) {
|
||||
constructor(method, response, errorData) {
|
||||
errorData.error = i18n.t('api_not_found')
|
||||
super(method, response, errorData)
|
||||
this.name = 'APINotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 500 — Server Internal Error
|
||||
class APIInternalError extends APIError {
|
||||
constructor (method, response, errorData) {
|
||||
constructor(method, response, errorData) {
|
||||
super(method, response, errorData)
|
||||
this.traceback = errorData.traceback || null
|
||||
this.name = 'APIInternalError'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 502 — Bad gateway (means API is down)
|
||||
class APINotRespondingError extends APIError {
|
||||
constructor (method, response) {
|
||||
constructor(method, response) {
|
||||
super(method, response, { error: i18n.t('api_not_responding') })
|
||||
this.name = 'APINotRespondingError'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Temp factory
|
||||
const errors = {
|
||||
[undefined]: APIError,
|
||||
|
@ -101,10 +97,9 @@ const errors = {
|
|||
401: APIUnauthorizedError,
|
||||
404: APINotFoundError,
|
||||
500: APIInternalError,
|
||||
502: APINotRespondingError
|
||||
502: APINotRespondingError,
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
errors as default,
|
||||
APIError,
|
||||
|
@ -114,5 +109,5 @@ export {
|
|||
APIInternalError,
|
||||
APINotFoundError,
|
||||
APINotRespondingError,
|
||||
APIUnauthorizedError
|
||||
APIUnauthorizedError,
|
||||
}
|
||||
|
|
|
@ -6,14 +6,13 @@
|
|||
import store from '@/store'
|
||||
import errors, { APIError } from './errors'
|
||||
|
||||
|
||||
/**
|
||||
* Try to get response content as json and if it's not as text.
|
||||
*
|
||||
* @param {Response} response - A fetch `Response` object.
|
||||
* @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
|
||||
const responseText = await response.text()
|
||||
try {
|
||||
|
@ -23,7 +22,6 @@ export async function getResponseData (response) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -33,11 +31,16 @@ export async function getResponseData (response) {
|
|||
* @param {Object} request - Request info data.
|
||||
* @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event.
|
||||
*/
|
||||
export function openWebSocket (request) {
|
||||
return new Promise(resolve => {
|
||||
const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`)
|
||||
export function openWebSocket(request) {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(
|
||||
`wss://${store.getters.host}/yunohost/api/messages`,
|
||||
)
|
||||
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.onopen = resolve
|
||||
|
@ -46,7 +49,6 @@ export function openWebSocket (request) {
|
|||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handler for API errors.
|
||||
*
|
||||
|
@ -55,7 +57,7 @@ export function openWebSocket (request) {
|
|||
* @param {Object|String} errorData - The response parsed json/text.
|
||||
* @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
|
||||
if (typeof errorData === 'string') {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {APIError} error
|
||||
*/
|
||||
export function onUnhandledAPIError (error) {
|
||||
export function onUnhandledAPIError(error) {
|
||||
error.log()
|
||||
store.dispatch('HANDLE_ERROR', error)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Global catching of unhandled promise's rejections.
|
||||
* Those errors (thrown or rejected from inside a promise) can't be catched by
|
||||
* `window.onerror`.
|
||||
*/
|
||||
export function registerGlobalErrorHandlers () {
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
export function registerGlobalErrorHandlers() {
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const error = e.reason
|
||||
if (error instanceof APIError) {
|
||||
onUnhandledAPIError(error)
|
||||
|
|
|
@ -24,8 +24,16 @@
|
|||
/>
|
||||
</BInputGroupAppend>
|
||||
|
||||
<span 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" />
|
||||
<span
|
||||
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>
|
||||
</template>
|
||||
|
||||
|
@ -42,17 +50,17 @@ export default {
|
|||
placeholder: { type: String, default: null },
|
||||
id: { type: String, default: null },
|
||||
state: { type: null, default: null },
|
||||
type: { type: String, default: 'email' }
|
||||
type: { type: String, default: 'email' },
|
||||
},
|
||||
|
||||
methods: {
|
||||
onInput (key, value) {
|
||||
onInput(key, value) {
|
||||
this.$emit('input', {
|
||||
...this.value,
|
||||
[key]: value
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<template>
|
||||
<BCard
|
||||
v-bind="$attrs"
|
||||
no-body :class="_class"
|
||||
>
|
||||
<BCard v-bind="$attrs" no-body :class="_class">
|
||||
<template #header>
|
||||
<slot name="header">
|
||||
<h2>
|
||||
<BButton v-b-toggle="id" :variant="variant" class="card-collapse-button">
|
||||
<BButton
|
||||
v-b-toggle="id"
|
||||
:variant="variant"
|
||||
class="card-collapse-button"
|
||||
>
|
||||
{{ title }}
|
||||
<YIcon class="ml-auto" iname="chevron-right" />
|
||||
</BButton>
|
||||
|
@ -29,21 +30,21 @@ export default {
|
|||
title: { type: String, required: true },
|
||||
variant: { type: String, default: 'white' },
|
||||
visible: { type: Boolean, default: false },
|
||||
flush: { type: Boolean, default: false }
|
||||
flush: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
computed: {
|
||||
_class () {
|
||||
_class() {
|
||||
const baseClass = 'card-collapse'
|
||||
return [
|
||||
baseClass,
|
||||
{
|
||||
[`${baseClass}-flush`]: this.flush,
|
||||
[`${baseClass}-${this.variant}`]: this.variant
|
||||
}
|
||||
[`${baseClass}-${this.variant}`]: this.variant,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -57,10 +58,10 @@ export default {
|
|||
display: flex;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding-top: $spacer * .5;
|
||||
padding-bottom: $spacer * .5;
|
||||
padding-top: $spacer * 0.5;
|
||||
padding-bottom: $spacer * 0.5;
|
||||
border-radius: 0;
|
||||
font: inherit
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
&-flush {
|
||||
|
|
|
@ -6,25 +6,30 @@ export default {
|
|||
name: 'CardDeckFeed',
|
||||
|
||||
props: {
|
||||
stacks: { type: Number, default: 21 }
|
||||
stacks: { type: Number, default: 21 },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
busy: false,
|
||||
range: this.stacks,
|
||||
childrenCount: this.$slots.default.length
|
||||
childrenCount: this.$slots.default.length,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getTopParent (prev) {
|
||||
return prev.parentElement === this.$refs.feed ? prev : this.getTopParent(prev.parentElement)
|
||||
getTopParent(prev) {
|
||||
return prev.parentElement === this.$refs.feed
|
||||
? prev
|
||||
: this.getTopParent(prev.parentElement)
|
||||
},
|
||||
|
||||
onScroll () {
|
||||
onScroll() {
|
||||
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.range = Math.min(this.range + this.stacks, this.childrenCount)
|
||||
this.$nextTick().then(() => {
|
||||
|
@ -33,7 +38,7 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
onKeydown (e) {
|
||||
onKeydown(e) {
|
||||
if (['PageUp', 'PageDown'].includes(e.code)) {
|
||||
e.preventDefault()
|
||||
const key = e.code === 'PageUp' ? 'previous' : 'next'
|
||||
|
@ -44,16 +49,16 @@ export default {
|
|||
}
|
||||
}
|
||||
// FIXME Add `Home` and `End` shorcuts
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted () {
|
||||
mounted() {
|
||||
window.addEventListener('scroll', this.onScroll)
|
||||
this.$refs.feed.addEventListener('keydown', this.onKeydown)
|
||||
this.onScroll()
|
||||
},
|
||||
|
||||
beforeUpdate () {
|
||||
beforeUpdate() {
|
||||
const slots = this.$slots.default
|
||||
if (this.childrenCount !== slots.length) {
|
||||
this.range = this.stacks
|
||||
|
@ -61,21 +66,21 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
render (h) {
|
||||
render(h) {
|
||||
return h(
|
||||
'BCardGroup',
|
||||
{
|
||||
attrs: { role: 'feed', 'aria-busy': this.busy.toString() },
|
||||
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)
|
||||
this.$refs.feed.removeEventListener('keydown', this.onKeydown)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
<template>
|
||||
<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"
|
||||
:no-footer="!panel.hasApplyButton"
|
||||
>
|
||||
|
@ -20,15 +24,20 @@
|
|||
class="panel-section"
|
||||
>
|
||||
<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>
|
||||
|
||||
<template v-for="(field, fname) in section.fields">
|
||||
<!-- FIXME rework the whole component chain to avoid direct mutation of the `forms` props -->
|
||||
<!-- eslint-disable -->
|
||||
<Component
|
||||
v-if="field.visible" :is="field.is" v-bind="field.props"
|
||||
v-model="forms[panel.id][fname]" :validation="validation[fname]" :key="fname"
|
||||
v-if="field.visible"
|
||||
: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)"
|
||||
/>
|
||||
<!-- eslint-enable -->
|
||||
|
@ -43,7 +52,6 @@
|
|||
<script>
|
||||
import { filterObject } from '@/helpers/commons'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'ConfigPanel',
|
||||
|
||||
|
@ -51,41 +59,43 @@ export default {
|
|||
tabId: { type: String, required: true },
|
||||
panels: { type: Array, default: undefined },
|
||||
forms: { type: Object, default: undefined },
|
||||
v: { type: Object, default: undefined }
|
||||
v: { type: Object, default: undefined },
|
||||
},
|
||||
|
||||
computed: {
|
||||
panel () {
|
||||
return this.panels.find(panel => panel.id === this.tabId)
|
||||
panel() {
|
||||
return this.panels.find((panel) => panel.id === this.tabId)
|
||||
},
|
||||
|
||||
validation () {
|
||||
validation() {
|
||||
return this.v.forms[this.panel.id]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onApply () {
|
||||
onApply() {
|
||||
const panelId = this.panel.id
|
||||
|
||||
this.$emit('submit', {
|
||||
id: panelId,
|
||||
form: this.forms[panelId]
|
||||
form: this.forms[panelId],
|
||||
})
|
||||
},
|
||||
|
||||
onAction (sectionId, actionId, actionFields) {
|
||||
onAction(sectionId, actionId, actionFields) {
|
||||
const panelId = this.panel.id
|
||||
const actionFieldsKeys = Object.keys(actionFields)
|
||||
|
||||
this.$emit('submit', {
|
||||
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('.'),
|
||||
name: actionId
|
||||
name: actionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ export default {
|
|||
name: 'ConfigPanels',
|
||||
|
||||
components: {
|
||||
RoutableTabs: () => import('@/components/RoutableTabs.vue')
|
||||
RoutableTabs: () => import('@/components/RoutableTabs.vue'),
|
||||
},
|
||||
|
||||
mixins: [validationMixin],
|
||||
|
@ -43,28 +43,28 @@ export default {
|
|||
validations: { type: Object, default: undefined },
|
||||
errors: { type: Object, default: undefined }, // never used
|
||||
routes: { type: Array, default: null },
|
||||
noRedirect: { type: Boolean, default: false }
|
||||
noRedirect: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
computed: {
|
||||
routes_ () {
|
||||
routes_() {
|
||||
if (this.routes) return this.routes
|
||||
return this.panels.map(panel => ({
|
||||
return this.panels.map((panel) => ({
|
||||
to: { params: { tabId: panel.id } },
|
||||
text: panel.name,
|
||||
icon: panel.icon || 'wrench'
|
||||
icon: panel.icon || 'wrench',
|
||||
}))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
validations () {
|
||||
validations() {
|
||||
return { forms: this.validations }
|
||||
},
|
||||
|
||||
created () {
|
||||
created() {
|
||||
if (!this.noRedirect && !this.$route.params.tabId) {
|
||||
this.$router.replace({ params: { tabId: this.panels[0].id } })
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -13,58 +13,64 @@ export default {
|
|||
minHeight: { type: Number, default: 0 },
|
||||
renderDelay: { type: Number, default: 100 },
|
||||
unrenderDelay: { type: Number, default: 2000 },
|
||||
rootMargin: { type: String, default: '300px' }
|
||||
rootMargin: { type: String, default: '300px' },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
observer: null,
|
||||
render: false,
|
||||
fixedMinHeight: this.minHeight
|
||||
fixedMinHeight: this.minHeight,
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
mounted() {
|
||||
let unrenderTimer
|
||||
let renderTimer
|
||||
|
||||
this.observer = new IntersectionObserver(entries => {
|
||||
let intersecting = entries[0].isIntersecting
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let intersecting = entries[0].isIntersecting
|
||||
|
||||
// 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,
|
||||
// isIntersecting is `false`, so we have to manually check this…
|
||||
// FIXME Would be great to find out why this is happening
|
||||
if (!intersecting && this.$el.offsetTop < window.innerHeight) {
|
||||
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()
|
||||
// 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,
|
||||
// isIntersecting is `false`, so we have to manually check this…
|
||||
// FIXME Would be great to find out why this is happening
|
||||
if (!intersecting && this.$el.offsetTop < window.innerHeight) {
|
||||
intersecting = true
|
||||
}
|
||||
} 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 })
|
||||
|
||||
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)
|
||||
// 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)
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
beforeDestroy() {
|
||||
this.observer.disconnect()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,17 +1,21 @@
|
|||
<template>
|
||||
<BListGroup
|
||||
v-bind="$attrs" flush
|
||||
:class="{ 'fixed-height': fixedHeight, 'bordered': bordered }"
|
||||
v-bind="$attrs"
|
||||
flush
|
||||
:class="{ 'fixed-height': fixedHeight, bordered: bordered }"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<YListGroupItem
|
||||
v-if="limit && messages.length > limit"
|
||||
variant="info" v-t="'api.partial_logs'"
|
||||
variant="info"
|
||||
v-t="'api.partial_logs'"
|
||||
/>
|
||||
|
||||
<YListGroupItem
|
||||
v-for="({ color, text }, i) in reducedMessages" :key="i"
|
||||
:variant="color" size="xs"
|
||||
v-for="({ color, text }, i) in reducedMessages"
|
||||
:key="i"
|
||||
:variant="color"
|
||||
size="xs"
|
||||
>
|
||||
<span v-html="text" />
|
||||
</YListGroupItem>
|
||||
|
@ -27,43 +31,43 @@ export default {
|
|||
fixedHeight: { type: Boolean, default: false },
|
||||
bordered: { type: Boolean, default: false },
|
||||
autoScroll: { type: Boolean, default: false },
|
||||
limit: { type: Number, default: null }
|
||||
limit: { type: Number, default: null },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
auto: true
|
||||
auto: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
reducedMessages () {
|
||||
reducedMessages() {
|
||||
const len = this.messages.length
|
||||
if (!this.limit || len <= this.limit) {
|
||||
return this.messages
|
||||
}
|
||||
return this.messages.slice(len - this.limit)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
scrollToEnd () {
|
||||
scrollToEnd() {
|
||||
if (!this.auto) return
|
||||
this.$nextTick(() => {
|
||||
this.$el.scrollTo(0, this.$el.lastElementChild.offsetTop)
|
||||
})
|
||||
},
|
||||
|
||||
onScroll ({ target }) {
|
||||
onScroll({ target }) {
|
||||
this.auto = target.scrollHeight === target.scrollTop + target.clientHeight
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
created() {
|
||||
if (this.autoScroll) {
|
||||
this.$watch('messages', this.scrollToEnd)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<div class="query-header w-100" v-on="$listeners" v-bind="$attrs">
|
||||
<!-- STATUS -->
|
||||
<span class="status" :class="['bg-' + color, statusSize]" :aria-label="$t('api.query_status.' + request.status)" />
|
||||
<span
|
||||
class="status"
|
||||
:class="['bg-' + color, statusSize]"
|
||||
:aria-label="$t('api.query_status.' + request.status)"
|
||||
/>
|
||||
|
||||
<!-- REQUEST DESCRIPTION -->
|
||||
<strong class="request-desc">
|
||||
|
@ -15,14 +19,16 @@
|
|||
</span>
|
||||
<!-- WEBSOCKET WARNINGS COUNT -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- VIEW ERROR BUTTON -->
|
||||
<BButton
|
||||
v-if="showError && request.error"
|
||||
size="sm" pill
|
||||
size="sm"
|
||||
pill
|
||||
class="error-btn ml-auto py-0"
|
||||
variant="danger"
|
||||
@click="reviewError"
|
||||
|
@ -31,7 +37,11 @@
|
|||
</BButton>
|
||||
|
||||
<!-- 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) }}
|
||||
</time>
|
||||
</div>
|
||||
|
@ -45,38 +55,40 @@ export default {
|
|||
request: { type: Object, required: true },
|
||||
statusSize: { type: String, default: '' },
|
||||
showTime: { type: Boolean, default: false },
|
||||
showError: { type: Boolean, default: false }
|
||||
showError: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
computed: {
|
||||
color () {
|
||||
color() {
|
||||
const statuses = {
|
||||
pending: 'primary',
|
||||
success: 'success',
|
||||
warning: 'warning',
|
||||
error: 'danger'
|
||||
error: 'danger',
|
||||
}
|
||||
return statuses[this.request.status]
|
||||
},
|
||||
|
||||
errorsCount () {
|
||||
return this.request.messages.filter(({ type }) => type === 'danger').length
|
||||
errorsCount() {
|
||||
return this.request.messages.filter(({ type }) => type === 'danger')
|
||||
.length
|
||||
},
|
||||
|
||||
warningsCount () {
|
||||
return this.request.messages.filter(({ type }) => type === 'warning').length
|
||||
}
|
||||
warningsCount() {
|
||||
return this.request.messages.filter(({ type }) => type === 'warning')
|
||||
.length
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
reviewError () {
|
||||
reviewError() {
|
||||
this.$store.dispatch('REVIEW_ERROR', this.request)
|
||||
},
|
||||
|
||||
hour (date) {
|
||||
hour(date) {
|
||||
return new Date(date).toLocaleTimeString()
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -98,15 +110,15 @@ div {
|
|||
.status {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
width: .75rem;
|
||||
min-width: .75rem;
|
||||
height: .75rem;
|
||||
margin-right: .25rem;
|
||||
width: 0.75rem;
|
||||
min-width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
margin-right: 0.25rem;
|
||||
|
||||
&.lg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: .5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,7 +130,7 @@ time {
|
|||
.count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
|
@ -126,5 +138,4 @@ time {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -3,16 +3,20 @@
|
|||
<template v-for="(node, i) in tree.children">
|
||||
<BListGroupItem
|
||||
: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)"
|
||||
>
|
||||
<slot name="default" v-bind="node" />
|
||||
|
||||
<BButton
|
||||
v-if="node.children"
|
||||
size="xs" variant="outline-secondary"
|
||||
:aria-expanded="node.data.opened ? 'true' : 'false'" :aria-controls="'collapse-' + node.id"
|
||||
:class="node.data.opened ? 'not-collapsed' : 'collapsed'" class="ml-2"
|
||||
size="xs"
|
||||
variant="outline-secondary"
|
||||
: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"
|
||||
>
|
||||
<span class="sr-only">{{ toggleText }}</span>
|
||||
|
@ -21,12 +25,15 @@
|
|||
</BListGroupItem>
|
||||
|
||||
<BCollapse
|
||||
v-if="node.children" :key="'collapse-' + node.id"
|
||||
v-model="node.data.opened" :id="'collapse-' + node.id"
|
||||
v-if="node.children"
|
||||
:key="'collapse-' + node.id"
|
||||
v-model="node.data.opened"
|
||||
:id="'collapse-' + node.id"
|
||||
>
|
||||
<RecursiveListGroup
|
||||
: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 -->
|
||||
<template slot="default" slot-scope="scope">
|
||||
|
@ -46,17 +53,20 @@ export default {
|
|||
tree: { type: Object, required: true },
|
||||
flush: { type: Boolean, default: false },
|
||||
last: { type: Boolean, default: undefined },
|
||||
toggleText: { type: String, default: null }
|
||||
toggleText: { type: String, default: null },
|
||||
},
|
||||
|
||||
methods: {
|
||||
getClasses (node, i) {
|
||||
getClasses(node, i) {
|
||||
const children = node.height > 0
|
||||
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 }
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -3,8 +3,11 @@
|
|||
<BCardHeader header-tag="nav">
|
||||
<BNav card-header fill pills>
|
||||
<BNavItem
|
||||
v-for="route in routes" :key="route.text"
|
||||
:to="route.to" exact exact-active-class="active"
|
||||
v-for="route in routes"
|
||||
:key="route.text"
|
||||
:to="route.to"
|
||||
exact
|
||||
exact-active-class="active"
|
||||
>
|
||||
<YIcon v-if="route.icon" :iname="route.icon" />
|
||||
{{ route.text }}
|
||||
|
@ -36,7 +39,7 @@ export default {
|
|||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
routes: { type: Array, required: true }
|
||||
}
|
||||
routes: { type: Array, required: true },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,16 +4,16 @@
|
|||
<slot name="disclaimer" />
|
||||
|
||||
<BForm
|
||||
:id="id" :inline="inline" :class="formClasses"
|
||||
@submit.prevent="onSubmit" novalidate
|
||||
:id="id"
|
||||
:inline="inline"
|
||||
:class="formClasses"
|
||||
@submit.prevent="onSubmit"
|
||||
novalidate
|
||||
>
|
||||
<slot name="default" />
|
||||
|
||||
<slot name="server-error" v-bind="{ errorFeedback }">
|
||||
<BAlert
|
||||
v-if="errorFeedback"
|
||||
variant="danger" class="my-3" icon="ban"
|
||||
>
|
||||
<BAlert v-if="errorFeedback" variant="danger" class="my-3" icon="ban">
|
||||
<div v-html="errorFeedback" />
|
||||
</BAlert>
|
||||
</slot>
|
||||
|
@ -41,28 +41,28 @@ export default {
|
|||
serverError: { type: String, default: '' },
|
||||
inline: { type: Boolean, default: false },
|
||||
formClasses: { type: [Array, String, Object], default: null },
|
||||
noFooter: { type: Boolean, default: false }
|
||||
noFooter: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
computed: {
|
||||
errorFeedback () {
|
||||
errorFeedback() {
|
||||
if (this.serverError) return this.serverError
|
||||
else if (this.validation && this.validation.$anyError) {
|
||||
return this.$i18n.t('form_errors.invalid_form')
|
||||
} else return ''
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit (e) {
|
||||
onSubmit(e) {
|
||||
const v = this.validation
|
||||
if (v) {
|
||||
v.$touch()
|
||||
if (v.$pending || v.$invalid) return
|
||||
}
|
||||
this.$emit('submit', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -73,7 +73,7 @@ export default {
|
|||
align-items: center;
|
||||
|
||||
& > *:not(:first-child) {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,14 +4,19 @@
|
|||
<slot name="disclaimer" />
|
||||
|
||||
<BForm
|
||||
:id="id" :inline="inline" :class="formClasses"
|
||||
@submit.prevent="onSubmit" novalidate
|
||||
:id="id"
|
||||
:inline="inline"
|
||||
:class="formClasses"
|
||||
@submit.prevent="onSubmit"
|
||||
novalidate
|
||||
>
|
||||
<slot name="default" />
|
||||
|
||||
<slot name="server-error">
|
||||
<BAlert
|
||||
variant="danger" class="my-3" icon="ban"
|
||||
variant="danger"
|
||||
class="my-3"
|
||||
icon="ban"
|
||||
:show="errorFeedback !== ''"
|
||||
>
|
||||
<div v-html="errorFeedback" />
|
||||
|
@ -41,30 +46,29 @@ export default {
|
|||
serverError: { type: String, default: '' },
|
||||
inline: { type: Boolean, default: false },
|
||||
formClasses: { type: [Array, String, Object], default: null },
|
||||
noFooter: { type: Boolean, default: false }
|
||||
noFooter: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
computed: {
|
||||
errorFeedback () {
|
||||
errorFeedback() {
|
||||
if (this.serverError) return this.serverError
|
||||
else if (this.validation && this.validation.$anyError) {
|
||||
return this.$i18n.t('form_errors.invalid_form')
|
||||
} else return ''
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit (e) {
|
||||
onSubmit(e) {
|
||||
const v = this.validation
|
||||
if (v) {
|
||||
v.$touch()
|
||||
if (v.$pending || v.$invalid) return
|
||||
}
|
||||
this.$emit('submit', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
<style lang="scss"></style>
|
||||
|
|
|
@ -21,21 +21,21 @@ export default {
|
|||
props: {
|
||||
term: { 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: {
|
||||
cols_ () {
|
||||
cols_() {
|
||||
return Object.assign({ md: 4, xl: 3 }, this.cols)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.description-row {
|
||||
@include media-breakpoint-up(md) {
|
||||
margin: .25rem 0;
|
||||
margin: 0.25rem 0;
|
||||
&:hover {
|
||||
background-color: rgba($black, 0.05);
|
||||
border-radius: 0.2rem;
|
||||
|
|
|
@ -2,17 +2,19 @@
|
|||
<span class="explain-what">
|
||||
<slot name="default" />
|
||||
<span class="explain-what-popover-container">
|
||||
<BButton
|
||||
:id="id" href="#"
|
||||
variant="light"
|
||||
>
|
||||
<BButton :id="id" href="#" variant="light">
|
||||
<YIcon iname="question" />
|
||||
<span class="sr-only">{{ $t('details_about', { subject: title }) }}</span>
|
||||
<span class="sr-only">
|
||||
{{ $t('details_about', { subject: title }) }}
|
||||
</span>
|
||||
</BButton>
|
||||
<BPopover
|
||||
placement="auto"
|
||||
:target="id" triggers="focus" custom-class="explain-what-popover"
|
||||
:variant="variant" :title="title"
|
||||
:target="id"
|
||||
triggers="focus"
|
||||
custom-class="explain-what-popover"
|
||||
:variant="variant"
|
||||
:title="title"
|
||||
>
|
||||
<span v-html="content" />
|
||||
</BPopover>
|
||||
|
@ -28,14 +30,14 @@ export default {
|
|||
id: { type: String, required: true },
|
||||
title: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
variant: { type: String, default: 'info' }
|
||||
variant: { type: String, default: 'info' },
|
||||
},
|
||||
|
||||
computed: {
|
||||
cols_ () {
|
||||
cols_() {
|
||||
return Object.assign({ md: 4, xl: 3 }, this.cols)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -45,7 +47,7 @@ export default {
|
|||
|
||||
.btn {
|
||||
padding: 0;
|
||||
margin-left: .1rem;
|
||||
margin-left: 0.1rem;
|
||||
border-radius: 50rem;
|
||||
line-height: inherit;
|
||||
font-size: inherit;
|
||||
|
|
|
@ -28,18 +28,18 @@
|
|||
<!-- Render description -->
|
||||
<template v-if="description || link">
|
||||
<div class="d-flex">
|
||||
<BLink
|
||||
v-if="link"
|
||||
:to="link" :href="link.href" class="ml-auto"
|
||||
>
|
||||
<BLink v-if="link" :to="link" :href="link.href" class="ml-auto">
|
||||
{{ link.text }}
|
||||
</BLink>
|
||||
</div>
|
||||
|
||||
<VueShowdown
|
||||
v-if="description"
|
||||
:markdown="description" flavor="github"
|
||||
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
|
||||
:markdown="description"
|
||||
flavor="github"
|
||||
:class="{
|
||||
['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<!-- Slot available to overwrite the one above -->
|
||||
|
@ -64,23 +64,23 @@ export default {
|
|||
component: { type: String, default: 'InputItem' },
|
||||
value: { type: null, default: null },
|
||||
props: { type: Object, default: () => ({}) },
|
||||
validation: { type: Object, default: null }
|
||||
validation: { type: Object, default: null },
|
||||
},
|
||||
|
||||
computed: {
|
||||
_id () {
|
||||
_id() {
|
||||
if (this.id) return this.id
|
||||
const childId = this.props.id || this.$attrs['label-for']
|
||||
return childId ? childId + '_group' : null
|
||||
},
|
||||
|
||||
attrs () {
|
||||
attrs() {
|
||||
const attrs = { ...this.$attrs }
|
||||
if ('label' in attrs) {
|
||||
const defaultAttrs = {
|
||||
'label-cols-md': 4,
|
||||
'label-cols-lg': 3,
|
||||
'label-class': ['font-weight-bold', 'py-0']
|
||||
'label-class': ['font-weight-bold', 'py-0'],
|
||||
}
|
||||
if (!('label-cols' in attrs)) {
|
||||
for (const attr in defaultAttrs) {
|
||||
|
@ -93,7 +93,7 @@ export default {
|
|||
return attrs
|
||||
},
|
||||
|
||||
state () {
|
||||
state() {
|
||||
// Need to set state as null if no error, else component turn green
|
||||
if (this.validation) {
|
||||
return this.validation.$anyError === true ? false : null
|
||||
|
@ -101,18 +101,18 @@ export default {
|
|||
return null
|
||||
},
|
||||
|
||||
errorMessage () {
|
||||
errorMessage() {
|
||||
const validation = this.validation
|
||||
if (validation && validation.$anyError) {
|
||||
const [type, errData] = this.findError(validation.$params, validation)
|
||||
return this.$i18n.t('form_errors.' + type, errData)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
touch (name) {
|
||||
touch(name) {
|
||||
if (this.validation) {
|
||||
// For fields that have multiple elements
|
||||
if (name) {
|
||||
|
@ -123,7 +123,7 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
findError (params, obj, parent = obj) {
|
||||
findError(params, obj, parent = obj) {
|
||||
for (const key in params) {
|
||||
if (!obj[key]) {
|
||||
return [key, obj.$params[key]]
|
||||
|
@ -132,8 +132,8 @@ export default {
|
|||
return this.findError(obj[key].$params, obj[key], parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -21,32 +21,35 @@ export default {
|
|||
label: { type: String, required: true },
|
||||
component: { type: String, default: 'InputItem' },
|
||||
value: { type: null, default: null },
|
||||
cols: { type: Object, default: () => ({ md: 4, lg: 3 }) }
|
||||
cols: { type: Object, default: () => ({ md: 4, lg: 3 }) },
|
||||
},
|
||||
|
||||
computed: {
|
||||
cols_ () {
|
||||
cols_() {
|
||||
return Object.assign({ md: 4, lg: 3 }, this.cols)
|
||||
},
|
||||
|
||||
text () {
|
||||
text() {
|
||||
return this.parseValue(this.value)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
parseValue (value) {
|
||||
parseValue(value) {
|
||||
const item = this.component
|
||||
if (item === 'FileItem') value = value.file ? value.file.name : null
|
||||
if (item === 'CheckboxItem') value = this.$i18n.t(value ? 'yes' : 'no')
|
||||
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -23,25 +23,25 @@ export default {
|
|||
button: {
|
||||
type: Object,
|
||||
default: null,
|
||||
validator (value) {
|
||||
return ['text', 'to'].every(prop => (prop in value))
|
||||
}
|
||||
}
|
||||
validator(value) {
|
||||
return ['text', 'to'].every((prop) => prop in value)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
hasLeftSlot: null,
|
||||
hasRightSlot: null
|
||||
hasRightSlot: null,
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
created() {
|
||||
this.$nextTick(() => {
|
||||
this.hasLeftSlot = 'group-left' in this.$slots
|
||||
this.hasRightSlot = 'group-right' in this.$slots
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -65,10 +65,10 @@ export default {
|
|||
flex-direction: column-reverse;
|
||||
|
||||
#top-bar-right {
|
||||
margin-bottom: .75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
::v-deep > * {
|
||||
margin-bottom: .25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,7 +89,7 @@ export default {
|
|||
}
|
||||
|
||||
::v-deep .btn {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
&.dropdown-toggle-split {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
|
|
@ -40,44 +40,46 @@ export default {
|
|||
queriesWait: { type: Boolean, default: false },
|
||||
skeleton: { type: [String, Array], default: null },
|
||||
// Optional prop to take control of the loading value
|
||||
loading: { type: Boolean, default: null }
|
||||
loading: { type: Boolean, default: null },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
fallback_loading: this.loading === null && this.queries !== null ? true : null
|
||||
fallback_loading:
|
||||
this.loading === null && this.queries !== null ? true : null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isLoading () {
|
||||
isLoading() {
|
||||
if (this.loading !== null) return this.loading
|
||||
return this.fallback_loading
|
||||
},
|
||||
|
||||
hasTopBar () {
|
||||
return ['top-bar-group-left', 'top-bar-group-right'].some(slotName => (slotName in this.$slots))
|
||||
}
|
||||
hasTopBar() {
|
||||
return ['top-bar-group-left', 'top-bar-group-right'].some(
|
||||
(slotName) => slotName in this.$slots,
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchQueries ({ triggerLoading = false } = {}) {
|
||||
fetchQueries({ triggerLoading = false } = {}) {
|
||||
if (triggerLoading) {
|
||||
this.fallback_loading = true
|
||||
}
|
||||
|
||||
api.fetchAll(
|
||||
this.queries,
|
||||
{ wait: this.queriesWait, initial: true }
|
||||
).then(responses => {
|
||||
this.$emit('queries-response', ...responses)
|
||||
this.fallback_loading = false
|
||||
})
|
||||
}
|
||||
api
|
||||
.fetchAll(this.queries, { wait: this.queriesWait, initial: true })
|
||||
.then((responses) => {
|
||||
this.$emit('queries-response', ...responses)
|
||||
this.fallback_loading = false
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
created() {
|
||||
if (this.queries) this.fetchQueries()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -11,8 +11,11 @@
|
|||
|
||||
<BFormInput
|
||||
id="top-bar-search"
|
||||
:value="search" @input="$emit('update:search', $event)"
|
||||
:placeholder="$t('search.for', { items: $tc('items.' + itemsName, 2) })"
|
||||
:value="search"
|
||||
@input="$emit('update:search', $event)"
|
||||
:placeholder="
|
||||
$t('search.for', { items: $tc('items.' + itemsName, 2) })
|
||||
"
|
||||
:disabled="!items"
|
||||
/>
|
||||
</BInputGroup>
|
||||
|
@ -29,7 +32,13 @@
|
|||
<BAlert v-if="items === null || filteredItems === null" variant="warning">
|
||||
<slot name="alert-message">
|
||||
<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>
|
||||
</BAlert>
|
||||
|
||||
|
@ -55,13 +64,13 @@ export default {
|
|||
itemsName: { type: String, required: true },
|
||||
filteredItems: { type: null, required: true },
|
||||
search: { type: String, default: null },
|
||||
skeleton: { type: String, default: 'ListGroupSkeleton' }
|
||||
skeleton: { type: String, default: 'ListGroupSkeleton' },
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasCustomTopBar () {
|
||||
hasCustomTopBar() {
|
||||
return 'top-bar' in this.$slots
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -23,14 +23,14 @@ export default {
|
|||
props: {
|
||||
alert: { type: Boolean, default: false },
|
||||
variant: { type: String, default: 'info' },
|
||||
icon: { type: String, default: null }
|
||||
icon: { type: String, default: null },
|
||||
},
|
||||
|
||||
computed: {
|
||||
_icon () {
|
||||
_icon() {
|
||||
if (this.icon) return this.icon
|
||||
return DEFAULT_STATUS_ICON[this.variant]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -6,8 +6,10 @@
|
|||
</BBreadcrumbItem>
|
||||
|
||||
<BBreadcrumbItem
|
||||
v-for="({ name, text }, i) in breadcrumb" :key="name"
|
||||
:to="{ name }" :active="i === breadcrumb.length - 1"
|
||||
v-for="({ name, text }, i) in breadcrumb"
|
||||
:key="name"
|
||||
:to="{ name }"
|
||||
:active="i === breadcrumb.length - 1"
|
||||
>
|
||||
{{ text }}
|
||||
</BBreadcrumbItem>
|
||||
|
@ -21,8 +23,8 @@ export default {
|
|||
name: 'YBreadcrumb',
|
||||
|
||||
computed: {
|
||||
...mapGetters(['breadcrumb'])
|
||||
}
|
||||
...mapGetters(['breadcrumb']),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -9,15 +9,29 @@
|
|||
<slot name="header-next" />
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BButton
|
||||
v-if="collapsable" @click="visible = !visible"
|
||||
size="sm" variant="outline-secondary"
|
||||
class="align-self-center ml-auto" :class="{ 'not-collapsed': visible, 'collapsed': !visible, [`ml-${buttonUnbreak}-2`]: buttonUnbreak }"
|
||||
v-if="collapsable"
|
||||
@click="visible = !visible"
|
||||
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" />
|
||||
<span class="sr-only">{{ $t('words.collapse') }}</span>
|
||||
|
@ -25,7 +39,7 @@
|
|||
</template>
|
||||
|
||||
<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>
|
||||
<slot name="default" />
|
||||
</BCardBody>
|
||||
|
@ -41,7 +55,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'YCard',
|
||||
|
||||
|
@ -52,20 +65,20 @@ export default {
|
|||
icon: { type: String, default: null },
|
||||
collapsable: { type: Boolean, default: false },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
buttonUnbreak: { type: String, default: 'md' }
|
||||
buttonUnbreak: { type: String, default: 'md' },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
visible: !this.collapsed
|
||||
visible: !this.collapsed,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasButtons () {
|
||||
hasButtons() {
|
||||
return 'header-buttons' in this.$slots
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -79,7 +92,7 @@ export default {
|
|||
}
|
||||
|
||||
.btn + .btn {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -90,7 +103,7 @@ export default {
|
|||
align-items: center;
|
||||
|
||||
& > *:not(:first-child) {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
.collapse:not(.show) + .card-footer {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
<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>
|
||||
|
||||
<script>
|
||||
|
@ -7,8 +10,8 @@ export default {
|
|||
name: 'YIcon',
|
||||
props: {
|
||||
iname: { type: String, required: true },
|
||||
variant: { type: String, default: null }
|
||||
}
|
||||
variant: { type: String, default: null },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -35,7 +38,7 @@ export default {
|
|||
}
|
||||
|
||||
&.variant {
|
||||
font-size: .8rem;
|
||||
font-size: 0.8rem;
|
||||
width: 1.35rem;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
<template>
|
||||
<BListGroupItem
|
||||
class="yuno-list-group-item" :class="_class"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<BListGroupItem class="yuno-list-group-item" :class="_class" v-bind="$attrs">
|
||||
<div v-if="!noStatus" class="yuno-list-group-item-status">
|
||||
<YIcon
|
||||
v-if="_icon" :iname="_icon"
|
||||
:class="['icon-' + variant]"
|
||||
/>
|
||||
<YIcon v-if="_icon" :iname="_icon" :class="['icon-' + variant]" />
|
||||
</div>
|
||||
|
||||
<div class="yuno-list-group-item-content">
|
||||
|
@ -28,28 +22,27 @@ export default {
|
|||
noIcon: { type: Boolean, default: false },
|
||||
noStatus: { type: Boolean, default: false },
|
||||
size: { type: String, default: 'md' },
|
||||
faded: { type: Boolean, default: false }
|
||||
faded: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
computed: {
|
||||
_icon () {
|
||||
_icon() {
|
||||
return this.noIcon ? null : this.icon || DEFAULT_STATUS_ICON[this.variant]
|
||||
},
|
||||
|
||||
_class () {
|
||||
_class() {
|
||||
const baseClass = 'yuno-list-group-item-'
|
||||
return [
|
||||
baseClass + this.size,
|
||||
baseClass + this.variant,
|
||||
{ [baseClass + 'faded']: this.faded }
|
||||
baseClass + this.size,
|
||||
baseClass + this.variant,
|
||||
{ [baseClass + 'faded']: this.faded },
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.yuno-list-group-item {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
|
@ -70,7 +63,7 @@ export default {
|
|||
&-#{$color} {
|
||||
color: theme-color-level($color, 6);
|
||||
|
||||
[dark-theme="true"] & {
|
||||
[dark-theme='true'] & {
|
||||
color: theme-color-level($color, -6);
|
||||
}
|
||||
|
||||
|
@ -96,7 +89,7 @@ export default {
|
|||
|
||||
&-xs {
|
||||
.yuno-list-group-item-status {
|
||||
width: .4rem;
|
||||
width: 0.4rem;
|
||||
|
||||
.icon {
|
||||
display: none;
|
||||
|
@ -109,7 +102,7 @@ export default {
|
|||
}
|
||||
|
||||
&-faded > * {
|
||||
opacity: .5;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,13 +5,12 @@
|
|||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'YSpinner',
|
||||
|
||||
computed: {
|
||||
...mapGetters(['spinner'])
|
||||
}
|
||||
...mapGetters(['spinner']),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -26,15 +25,28 @@ export default {
|
|||
background-image: url('../../assets/spinners/pacman_dark.gif');
|
||||
animation-name: back-and-forth-pacman;
|
||||
|
||||
[dark-theme="true"] & {
|
||||
[dark-theme='true'] & {
|
||||
background-image: url('../../assets/spinners/pacman_light.gif');
|
||||
}
|
||||
|
||||
@keyframes back-and-forth-pacman {
|
||||
0%, 100% { transform: scale(1); 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;}
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
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;
|
||||
|
||||
@keyframes back-and-forth-magikarp {
|
||||
0%, 100% { transform: scale(1, 1); 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;}
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1, 1);
|
||||
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;
|
||||
|
||||
@keyframes back-and-forth-nyancat {
|
||||
0%, 100% { transform: scale(1, 1); 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;}
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1, 1);
|
||||
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;
|
||||
|
||||
@keyframes back-and-forth-spookycat {
|
||||
0%, 100% { transform: scale(1, 1); 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;}
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1, 1);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'ButtonItem',
|
||||
|
||||
|
@ -21,20 +20,20 @@ export default {
|
|||
id: { type: String, default: null },
|
||||
type: { type: String, default: 'success' },
|
||||
icon: { type: String, default: null },
|
||||
enabled: { type: [Boolean, String], default: true }
|
||||
enabled: { type: [Boolean, String], default: true },
|
||||
},
|
||||
|
||||
computed: {
|
||||
icon_ () {
|
||||
icon_() {
|
||||
const icons = {
|
||||
success: 'thumbs-up',
|
||||
info: 'info',
|
||||
warning: 'exclamation',
|
||||
danger: 'times'
|
||||
danger: 'times',
|
||||
}
|
||||
|
||||
return this.icon || icons[this.type]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -18,13 +18,13 @@ export default {
|
|||
value: { type: Boolean, required: true },
|
||||
id: { 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 {
|
||||
checked: this.value
|
||||
checked: this.value,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -10,7 +10,7 @@ export default {
|
|||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
label: { type: String, default: null }
|
||||
}
|
||||
label: { type: String, default: null },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
<BButtonGroup class="w-100">
|
||||
<BButton
|
||||
v-if="!this.required && this.value.file !== null"
|
||||
@click="clearFiles" variant="danger"
|
||||
@click="clearFiles"
|
||||
variant="danger"
|
||||
>
|
||||
<span class="sr-only">{{ $t('delete') }}</span>
|
||||
<YIcon iname="trash" />
|
||||
|
@ -39,42 +40,42 @@ export default {
|
|||
accept: { type: String, default: null },
|
||||
state: { type: Boolean, default: null },
|
||||
required: { type: Boolean, default: false },
|
||||
name: { type: String, default: null }
|
||||
name: { type: String, default: null },
|
||||
},
|
||||
|
||||
computed: {
|
||||
_placeholder: function () {
|
||||
return this.value.file === null ? this.placeholder : this.value.file.name
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onInput (file) {
|
||||
onInput(file) {
|
||||
const value = {
|
||||
file,
|
||||
content: '',
|
||||
current: false,
|
||||
removed: false
|
||||
removed: false,
|
||||
}
|
||||
// Update the value with the new File and an empty content for now
|
||||
this.$emit('input', value)
|
||||
|
||||
// Asynchronously load the File content and update the value again
|
||||
getFileContent(file).then(content => {
|
||||
getFileContent(file).then((content) => {
|
||||
this.$emit('input', { ...value, content })
|
||||
})
|
||||
},
|
||||
|
||||
clearFiles () {
|
||||
clearFiles() {
|
||||
this.$refs['input-file'].reset()
|
||||
this.$emit('input', {
|
||||
file: null,
|
||||
content: '',
|
||||
current: false,
|
||||
removed: true
|
||||
removed: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'InputItem',
|
||||
|
||||
|
@ -34,13 +33,17 @@ export default {
|
|||
trim: { type: Boolean, default: true },
|
||||
autocomplete: { type: String, default: null },
|
||||
pattern: { type: Object, default: null },
|
||||
name: { type: String, default: null }
|
||||
name: { type: String, default: null },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
|
||||
autocomplete_: this.autocomplete
|
||||
? this.autocomplete
|
||||
: this.type === 'password'
|
||||
? 'new-password'
|
||||
: null,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -8,7 +8,7 @@ export default {
|
|||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
label: { type: String, default: null }
|
||||
}
|
||||
label: { type: String, default: null },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
<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" />
|
||||
|
||||
<VueShowdown
|
||||
:markdown="label" flavor="github"
|
||||
tag="span" class="markdown"
|
||||
:markdown="label"
|
||||
flavor="github"
|
||||
tag="span"
|
||||
class="markdown"
|
||||
/>
|
||||
</BAlert>
|
||||
</template>
|
||||
|
@ -17,19 +23,19 @@ export default {
|
|||
id: { type: String, default: null },
|
||||
label: { type: String, default: null },
|
||||
type: { type: String, default: null },
|
||||
icon: { type: String, default: null }
|
||||
icon: { type: String, default: null },
|
||||
},
|
||||
|
||||
computed: {
|
||||
icon_ () {
|
||||
icon_() {
|
||||
const icons = {
|
||||
success: 'thumbs-up',
|
||||
info: 'info',
|
||||
warning: 'exclamation',
|
||||
danger: 'times'
|
||||
danger: 'times',
|
||||
}
|
||||
return this.icon || icons[this.type]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -18,7 +18,7 @@ export default {
|
|||
id: { type: String, default: null },
|
||||
choices: { type: [Array, Object], required: true },
|
||||
required: { type: Boolean, default: false },
|
||||
name: { type: String, default: null }
|
||||
}
|
||||
name: { type: String, default: null },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -17,9 +17,9 @@
|
|||
export default {
|
||||
name: 'TagsItem',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
tags: this.value
|
||||
tags: this.value,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
|
@ -29,8 +29,7 @@ export default {
|
|||
limit: { type: Number, default: null },
|
||||
required: { type: Boolean, default: false },
|
||||
state: { type: Boolean, default: null },
|
||||
name: { type: String, default: null }
|
||||
}
|
||||
name: { type: String, default: null },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,13 +1,24 @@
|
|||
<template>
|
||||
<div class="tags-selectize">
|
||||
<BFormTags
|
||||
v-bind="$attrs" v-on="$listeners"
|
||||
:value="value" :id="id"
|
||||
size="lg" class="p-0 border-0" no-outer-focus
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
:value="value"
|
||||
:id="id"
|
||||
size="lg"
|
||||
class="p-0 border-0"
|
||||
no-outer-focus
|
||||
>
|
||||
<template #default="{ tags, disabled, addTag, removeTag }">
|
||||
<ul 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">
|
||||
<ul
|
||||
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
|
||||
@remove="onRemoveTag({ option: tag, removeTag })"
|
||||
:title="tag"
|
||||
|
@ -21,7 +32,9 @@
|
|||
|
||||
<BDropdown
|
||||
ref="dropdown"
|
||||
variant="outline-dark" block menu-class="w-100"
|
||||
variant="outline-dark"
|
||||
block
|
||||
menu-class="w-100"
|
||||
@keydown.native="onDropdownKeydown"
|
||||
>
|
||||
<template #button-content>
|
||||
|
@ -32,15 +45,25 @@
|
|||
<BDropdownForm @submit.stop.prevent="() => {}">
|
||||
<BFormGroup
|
||||
:label="$t('search.for', { items: itemsName })"
|
||||
label-cols-md="auto" label-size="sm" :label-for="id + '-search-input'"
|
||||
:invalid-feedback="$tc('search.not_found', 0, { items: $tc('items.' + itemsName, 0) })"
|
||||
:state="searchState" :disabled="disabled"
|
||||
label-cols-md="auto"
|
||||
label-size="sm"
|
||||
:label-for="id + '-search-input'"
|
||||
:invalid-feedback="
|
||||
$tc('search.not_found', 0, {
|
||||
items: $tc('items.' + itemsName, 0),
|
||||
})
|
||||
"
|
||||
:state="searchState"
|
||||
:disabled="disabled"
|
||||
class="mb-0"
|
||||
>
|
||||
<BFormInput
|
||||
ref="search-input" v-model="search"
|
||||
ref="search-input"
|
||||
v-model="search"
|
||||
:id="id + '-search-input'"
|
||||
type="search" size="sm" autocomplete="off"
|
||||
type="search"
|
||||
size="sm"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</BFormGroup>
|
||||
</BDropdownForm>
|
||||
|
@ -56,7 +79,11 @@
|
|||
</BDropdownItemButton>
|
||||
<BDropdownText v-if="!criteria && availableOptions.length === 0">
|
||||
<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>
|
||||
</BDropdown>
|
||||
</template>
|
||||
|
@ -76,43 +103,45 @@ export default {
|
|||
limit: { type: Number, default: null },
|
||||
name: { type: String, default: null },
|
||||
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'.
|
||||
auto: { type: Boolean, default: false },
|
||||
noTags: { type: Boolean, default: false },
|
||||
label: { type: String, default: null },
|
||||
tagIcon: { type: String, default: null }
|
||||
tagIcon: { type: String, default: null },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
search: ''
|
||||
search: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
criteria () {
|
||||
criteria() {
|
||||
return this.search.trim().toLowerCase()
|
||||
},
|
||||
|
||||
availableOptions () {
|
||||
availableOptions() {
|
||||
const criteria = this.criteria
|
||||
const options = this.options.filter(opt => {
|
||||
return this.value.indexOf(opt) === -1 && !this.disabledItems.includes(opt)
|
||||
const options = this.options.filter((opt) => {
|
||||
return (
|
||||
this.value.indexOf(opt) === -1 && !this.disabledItems.includes(opt)
|
||||
)
|
||||
})
|
||||
if (criteria) {
|
||||
return options.filter(opt => opt.toLowerCase().indexOf(criteria) > -1)
|
||||
return options.filter((opt) => opt.toLowerCase().indexOf(criteria) > -1)
|
||||
}
|
||||
return options
|
||||
},
|
||||
|
||||
searchState () {
|
||||
searchState() {
|
||||
return this.criteria && this.availableOptions.length === 0 ? false : null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onAddTag ({ option, addTag }) {
|
||||
onAddTag({ option, addTag }) {
|
||||
this.$emit('tag-update', { action: 'add', option, applyMethod: addTag })
|
||||
this.search = ''
|
||||
if (this.auto) {
|
||||
|
@ -120,14 +149,18 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
onRemoveTag ({ option, removeTag }) {
|
||||
this.$emit('tag-update', { action: 'remove', option, applyMethod: removeTag })
|
||||
onRemoveTag({ option, removeTag }) {
|
||||
this.$emit('tag-update', {
|
||||
action: 'remove',
|
||||
option,
|
||||
applyMethod: removeTag,
|
||||
})
|
||||
if (this.auto) {
|
||||
removeTag(option)
|
||||
}
|
||||
},
|
||||
|
||||
onDropdownKeydown (e) {
|
||||
onDropdownKeydown(e) {
|
||||
// Allow to start searching after dropdown opening
|
||||
if (
|
||||
!['Tab', 'Space'].includes(e.code) &&
|
||||
|
@ -135,8 +168,8 @@ export default {
|
|||
) {
|
||||
this.$refs['search-input'].focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -147,7 +180,7 @@ export default {
|
|||
padding-top: 0;
|
||||
|
||||
.search-group {
|
||||
padding-top: .5rem;
|
||||
padding-top: 0.5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: $white;
|
||||
|
|
|
@ -22,7 +22,7 @@ export default {
|
|||
type: { type: String, default: 'text' },
|
||||
required: { type: Boolean, default: false },
|
||||
state: { type: Boolean, default: null },
|
||||
name: { type: String, default: null }
|
||||
}
|
||||
name: { type: String, default: null },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<BSkeleton v-else :width="randint(45, 100) + '%'" height="24px" />
|
||||
|
||||
<BSkeleton :width="randint(20, 30) + '%'" height="38px" class="mt-3" />
|
||||
<hr>
|
||||
<hr />
|
||||
</div>
|
||||
</BCard>
|
||||
</template>
|
||||
|
@ -24,9 +24,9 @@ export default {
|
|||
name: 'CardButtonsSkeleton',
|
||||
|
||||
props: {
|
||||
itemCount: { type: Number, default: 5 }
|
||||
itemCount: { type: Number, default: 5 },
|
||||
},
|
||||
|
||||
methods: { randint }
|
||||
methods: { randint },
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -8,12 +8,19 @@
|
|||
<BRow :key="count" :class="{ 'd-block': cols === null }">
|
||||
<BCol v-bind="cols">
|
||||
<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>
|
||||
</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="38px" height="38px" class="ml-2" />
|
||||
|
@ -25,7 +32,7 @@
|
|||
</BCol>
|
||||
</BRow>
|
||||
|
||||
<hr :key="count + '-hr'">
|
||||
<hr :key="count + '-hr'" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
|
@ -44,9 +51,14 @@ export default {
|
|||
|
||||
props: {
|
||||
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>
|
||||
|
|
|
@ -22,9 +22,9 @@ export default {
|
|||
name: 'CardInfoSkeleton',
|
||||
|
||||
props: {
|
||||
itemCount: { type: Number, default: 5 }
|
||||
itemCount: { type: Number, default: 5 },
|
||||
},
|
||||
|
||||
methods: { randint }
|
||||
methods: { randint },
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -6,8 +6,12 @@
|
|||
|
||||
<BListGroup flush>
|
||||
<BListGroupItem v-for="count in itemCount" :key="count" class="d-flex">
|
||||
<div style="width: 20%;">
|
||||
<BSkeleton :width="randint(50, 100) + '%'" height="24px" class="mr-3" />
|
||||
<div style="width: 20%">
|
||||
<BSkeleton
|
||||
:width="randint(50, 100) + '%'"
|
||||
height="24px"
|
||||
class="mr-3"
|
||||
/>
|
||||
</div>
|
||||
<BSkeleton :width="randint(30, 80) + '%'" height="24px" class="m-0" />
|
||||
</BListGroupItem>
|
||||
|
@ -22,9 +26,9 @@ export default {
|
|||
name: 'CardListSkeleton',
|
||||
|
||||
props: {
|
||||
itemCount: { type: Number, default: 5 }
|
||||
itemCount: { type: Number, default: 5 },
|
||||
},
|
||||
|
||||
methods: { randint }
|
||||
methods: { randint },
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -14,9 +14,9 @@ export default {
|
|||
name: 'ListGroupSkeleton',
|
||||
|
||||
props: {
|
||||
itemCount: { type: Number, default: 5 }
|
||||
itemCount: { type: Number, default: 5 },
|
||||
},
|
||||
|
||||
methods: { randint }
|
||||
methods: { randint },
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* @param {Number} delay - delay after which the promise is rejected
|
||||
* @return {Promise}
|
||||
*/
|
||||
export function timeout (promise, delay) {
|
||||
export function timeout(promise, delay) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// FIXME reject(new Error('api_not_responding')) for post-install
|
||||
setTimeout(() => reject, delay)
|
||||
|
@ -15,18 +15,20 @@ export function timeout (promise, delay) {
|
|||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if passed value is an object literal.
|
||||
*
|
||||
* @param {*} value - Anything.
|
||||
* @return {Boolean}
|
||||
*/
|
||||
export function isObjectLiteral (value) {
|
||||
return value !== null && value !== undefined && Object.is(value.constructor, Object)
|
||||
export function isObjectLiteral(value) {
|
||||
return (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
Object.is(value.constructor, Object)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if value is "empty" (`null`, `undefined`, `''`, `[]`, '{}').
|
||||
* Note: `0` is not considered "empty" in that helper.
|
||||
|
@ -34,12 +36,11 @@ export function isObjectLiteral (value) {
|
|||
* @param {*} value - Anything.
|
||||
* @return {Boolean}
|
||||
*/
|
||||
export function isEmptyValue (value) {
|
||||
export function isEmptyValue(value) {
|
||||
if (typeof value === 'number') return false
|
||||
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.
|
||||
*
|
||||
|
@ -47,8 +48,8 @@ export function isEmptyValue (value) {
|
|||
* @param {Object} [flattened={}] - An object literal to add passed obj keys/values.
|
||||
* @return {Object}
|
||||
*/
|
||||
export function flattenObjectLiteral (obj, flattened = {}) {
|
||||
function flatten (objLit) {
|
||||
export function flattenObjectLiteral(obj, flattened = {}) {
|
||||
function flatten(objLit) {
|
||||
for (const key in objLit) {
|
||||
const value = objLit[key]
|
||||
if (isObjectLiteral(value)) {
|
||||
|
@ -62,7 +63,6 @@ export function flattenObjectLiteral (obj, flattened = {}) {
|
|||
return flattened
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an new Object filtered with passed 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.
|
||||
* @return {Object}
|
||||
*/
|
||||
export function filterObject (obj, filter) {
|
||||
return Object.fromEntries(Object.entries(obj).filter((...args) => filter(...args)))
|
||||
export function filterObject(obj, filter) {
|
||||
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.
|
||||
*
|
||||
|
@ -83,18 +84,17 @@ export function filterObject (obj, filter) {
|
|||
* @param {Array} [arr2=[]]
|
||||
* @return {Array}
|
||||
*/
|
||||
export function arrayDiff (arr1 = [], arr2 = []) {
|
||||
return arr1.filter(item => !arr2.includes(item))
|
||||
export function arrayDiff(arr1 = [], arr2 = []) {
|
||||
return arr1.filter((item) => !arr2.includes(item))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a new string with escaped HTML (`&<>"'` replaced by entities).
|
||||
*
|
||||
* @param {String} unsafe
|
||||
* @return {String}
|
||||
*/
|
||||
export function escapeHtml (unsafe) {
|
||||
export function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
|
@ -110,11 +110,10 @@ export function escapeHtml (unsafe) {
|
|||
* @param {Number} max
|
||||
* @return {Number}
|
||||
*/
|
||||
export function randint (min, max) {
|
||||
export function randint(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a File content.
|
||||
*
|
||||
|
@ -123,7 +122,7 @@ export function randint (min, max) {
|
|||
* @param {Boolean} [extraParams.base64] - returns a base64 representation of the file.
|
||||
* @return {Promise<String>}
|
||||
*/
|
||||
export function getFileContent (file, { base64 = false } = {}) {
|
||||
export function getFileContent(file, { base64 = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = reject
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* A Node that can have a parent and children.
|
||||
*/
|
||||
export class Node {
|
||||
constructor (data) {
|
||||
constructor(data) {
|
||||
this.data = data
|
||||
this.depth = 0
|
||||
this.height = 0
|
||||
|
@ -22,7 +22,7 @@ export class Node {
|
|||
* @param {function} callback
|
||||
* @return {Object}
|
||||
*/
|
||||
eachBefore (callback) {
|
||||
eachBefore(callback) {
|
||||
const nodes = []
|
||||
let index = -1
|
||||
let node = this
|
||||
|
@ -49,7 +49,7 @@ export class Node {
|
|||
* @param {function} callback
|
||||
* @return {Object}
|
||||
*/
|
||||
eachAfter (callback) {
|
||||
eachAfter(callback) {
|
||||
const nodes = []
|
||||
const next = []
|
||||
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.
|
||||
* @return {Node}
|
||||
*/
|
||||
filter (callback) {
|
||||
filter(callback) {
|
||||
// Duplicates this tree and iter on nodes from leaves to root (post-order traversal)
|
||||
return hierarchy(this).eachAfter((node, i) => {
|
||||
// Since we create a new hierarchy from another, nodes's `data` contains the
|
||||
|
@ -90,7 +90,7 @@ export class Node {
|
|||
|
||||
if (node.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
|
||||
}
|
||||
|
||||
|
@ -104,7 +104,6 @@ export class Node {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates a new hierarchy from the specified tabular `dataset`.
|
||||
* 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.
|
||||
* @return {Node}
|
||||
*/
|
||||
export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } = {}) {
|
||||
export function stratify(
|
||||
dataset,
|
||||
{ idKey = 'name', parentIdKey = 'parent' } = {},
|
||||
) {
|
||||
const root = new Node(null, true)
|
||||
root.children = []
|
||||
const nodesMap = new Map()
|
||||
|
||||
// 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)
|
||||
node.id = d[idKey]
|
||||
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
|
||||
if (node.parent) {
|
||||
node.depth = node.parent.depth + 1
|
||||
|
@ -160,7 +162,6 @@ export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } =
|
|||
return root
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a root node from the specified hierarchical `data`.
|
||||
* 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`)
|
||||
* @return {Node}
|
||||
*/
|
||||
export function hierarchy (data) {
|
||||
export function hierarchy(data) {
|
||||
const root = new Node(data)
|
||||
const nodes = []
|
||||
let node = root
|
||||
|
||||
while (node) {
|
||||
if (node.data.children) {
|
||||
node.children = node.data.children.map(child_ => {
|
||||
node.children = node.data.children.map((child_) => {
|
||||
const child = new Node(child_)
|
||||
child.id = child_.id
|
||||
child.parent = node === root ? null : node
|
||||
|
@ -193,14 +194,13 @@ export function hierarchy (data) {
|
|||
return root
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {Node} node
|
||||
*/
|
||||
function computeNodeHeight (node) {
|
||||
function computeNodeHeight(node) {
|
||||
let height = 0
|
||||
do {
|
||||
node.height = height
|
||||
|
|
|
@ -3,17 +3,13 @@ import format from 'date-fns/format'
|
|||
|
||||
import { dateFnsLocale as locale } from '@/i18n/helpers'
|
||||
|
||||
export function distanceToNow (date, addSuffix = true, isTimestamp = false) {
|
||||
return formatDistanceToNow(
|
||||
new Date(isTimestamp ? date * 1000 : date),
|
||||
{ addSuffix, locale }
|
||||
)
|
||||
export function distanceToNow(date, addSuffix = true, isTimestamp = false) {
|
||||
return formatDistanceToNow(new Date(isTimestamp ? date * 1000 : date), {
|
||||
addSuffix,
|
||||
locale,
|
||||
})
|
||||
}
|
||||
|
||||
export function readableDate (date, isTimestamp = false) {
|
||||
return format(
|
||||
new Date(isTimestamp ? date * 1000 : date),
|
||||
'PPPpp',
|
||||
{ locale }
|
||||
)
|
||||
export function readableDate(date, isTimestamp = false) {
|
||||
return format(new Date(isTimestamp ? date * 1000 : date), 'PPPpp', { locale })
|
||||
}
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
export function humanSize (bytes) {
|
||||
export function humanSize(bytes) {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
if (bytes === 0) return 'n/a'
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)))
|
||||
return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
|
||||
export function humanPermissionName (text) {
|
||||
return text.split('.')[1].replace('_', ' ').replace(/\w\S*/g, part => {
|
||||
return part.charAt(0).toUpperCase() + part.substr(1).toLowerCase()
|
||||
})
|
||||
export function humanPermissionName(text) {
|
||||
return text
|
||||
.split('.')[1]
|
||||
.replace('_', ' ')
|
||||
.replace(/\w\S*/g, (part) => {
|
||||
return part.charAt(0).toUpperCase() + part.substr(1).toLowerCase()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,52 +1,70 @@
|
|||
import { helpers } from 'vuelidate/lib/validators'
|
||||
|
||||
|
||||
// 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 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 emailForwardLocalPart = helpers.regex('emailForwardLocalPart', /^[\w+.-]+$/)
|
||||
const emailForwardLocalPart = helpers.regex(
|
||||
'emailForwardLocalPart',
|
||||
/^[\w+.-]+$/,
|
||||
)
|
||||
|
||||
const email = value => helpers.withParams(
|
||||
{ type: 'email', value },
|
||||
value => {
|
||||
const email = (value) =>
|
||||
helpers.withParams({ type: 'email', value }, (value) => {
|
||||
const [localPart, domainPart] = value.split('@')
|
||||
if (!domainPart) return !helpers.req(value) || false
|
||||
return !helpers.req(value) || (emailLocalPart(localPart) && domain(domainPart))
|
||||
}
|
||||
)(value)
|
||||
return (
|
||||
!helpers.req(value) || (emailLocalPart(localPart) && domain(domainPart))
|
||||
)
|
||||
})(value)
|
||||
|
||||
// Same as email but with `+` allowed.
|
||||
const emailForward = value => helpers.withParams(
|
||||
{ type: 'emailForward', value },
|
||||
value => {
|
||||
const emailForward = (value) =>
|
||||
helpers.withParams({ type: 'emailForward', value }, (value) => {
|
||||
const [localPart, domainPart] = value.split('@')
|
||||
if (!domainPart) return !helpers.req(value) || false
|
||||
return !helpers.req(value) || (emailForwardLocalPart(localPart) && domain(domainPart))
|
||||
}
|
||||
)(value)
|
||||
return (
|
||||
!helpers.req(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(
|
||||
{ type: 'includes', value: item },
|
||||
item => !helpers.req(item) || (items ? items.includes(item) : false)
|
||||
)(item)
|
||||
const includes = (items) => (item) =>
|
||||
helpers.withParams(
|
||||
{ type: 'includes', value: 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(
|
||||
{ type: 'unique', arg: items, value: item },
|
||||
item => items ? !helpers.req(item) || !items.includes(item) : true
|
||||
)(item)
|
||||
const unique = (items) => (item) =>
|
||||
helpers.withParams({ type: 'unique', arg: items, value: item }, (item) =>
|
||||
items ? !helpers.req(item) || !items.includes(item) : true,
|
||||
)(item)
|
||||
|
||||
export {
|
||||
alphalownumdot_,
|
||||
|
@ -59,5 +77,5 @@ export {
|
|||
appRepoUrl,
|
||||
includes,
|
||||
name,
|
||||
unique
|
||||
unique,
|
||||
}
|
||||
|
|
|
@ -8,5 +8,5 @@ export {
|
|||
minLength,
|
||||
minValue,
|
||||
required,
|
||||
sameAs
|
||||
sameAs,
|
||||
} from 'vuelidate/lib/validators'
|
||||
|
|
|
@ -6,16 +6,15 @@ import {
|
|||
isObjectLiteral,
|
||||
isEmptyValue,
|
||||
flattenObjectLiteral,
|
||||
getFileContent
|
||||
getFileContent,
|
||||
} from '@/helpers/commons'
|
||||
|
||||
|
||||
const NO_VALUE_FIELDS = [
|
||||
'ReadOnlyField',
|
||||
'ReadOnlyAlertItem',
|
||||
'MarkdownItem',
|
||||
'DisplayTextItem',
|
||||
'ButtonItem'
|
||||
'ButtonItem',
|
||||
]
|
||||
|
||||
export const DEFAULT_STATUS_ICON = {
|
||||
|
@ -24,7 +23,7 @@ export const DEFAULT_STATUS_ICON = {
|
|||
error: 'times',
|
||||
info: 'info',
|
||||
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
|
||||
* @return {String}
|
||||
*/
|
||||
export function formatI18nField (field) {
|
||||
export function formatI18nField(field) {
|
||||
if (typeof field === 'string') return field
|
||||
const { locale, fallbackLocale } = store.state
|
||||
return field ? field[locale] || field[fallbackLocale] || field.en : ''
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a string size declaration to a M value.
|
||||
*
|
||||
* @param {String} sizeStr - A size declared like '500M' or '56k'
|
||||
* @return {Number}
|
||||
*/
|
||||
export function sizeToM (sizeStr) {
|
||||
export function sizeToM(sizeStr) {
|
||||
const unit = sizeStr.slice(-1)
|
||||
const value = sizeStr.slice(0, -1)
|
||||
if (unit === 'M') return parseInt(value)
|
||||
|
@ -57,20 +55,18 @@ export function sizeToM (sizeStr) {
|
|||
if (unit === 'T') return Math.ceil(value * 1024 * 1024)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a formatted address element to be used by AdressInputSelect component.
|
||||
*
|
||||
* @param {String} address - A string representing an adress (subdomain or email)
|
||||
* @return {Object} - `{ localPart, separator, domain }`.
|
||||
*/
|
||||
export function adressToFormValue (address) {
|
||||
export function adressToFormValue(address) {
|
||||
const separator = address.includes('@') ? '@' : '.'
|
||||
const [localPart, domain] = address.split(separator)
|
||||
return { localPart, separator, domain }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Evaluate config panel string expression that can contain regular expressions.
|
||||
* 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.
|
||||
* @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 === '"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.
|
||||
function addEvaluationGetter (prop, obj, expr, ctx, nested) {
|
||||
function addEvaluationGetter(prop, obj, expr, ctx, nested) {
|
||||
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
|
||||
* 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.
|
||||
* @return {Object} an formated argument containing formItem props, validation and base value.
|
||||
*/
|
||||
export function formatYunoHostArgument (arg) {
|
||||
let value = (arg.value !== undefined) ? arg.value : (arg.current_value !== undefined) ? arg.current_value : null
|
||||
export function formatYunoHostArgument(arg) {
|
||||
let value =
|
||||
arg.value !== undefined
|
||||
? arg.value
|
||||
: arg.current_value !== undefined
|
||||
? arg.current_value
|
||||
: null
|
||||
const validation = {}
|
||||
const error = { message: null }
|
||||
arg.ask = formatI18nField(arg.ask)
|
||||
|
@ -135,8 +135,8 @@ export function formatYunoHostArgument (arg) {
|
|||
props: {
|
||||
label: arg.ask,
|
||||
component: undefined,
|
||||
props: {}
|
||||
}
|
||||
props: {},
|
||||
},
|
||||
}
|
||||
|
||||
const defaultProps = ['id', 'placeholder:example']
|
||||
|
@ -144,12 +144,12 @@ export function formatYunoHostArgument (arg) {
|
|||
{
|
||||
types: ['string', 'path'],
|
||||
name: 'InputItem',
|
||||
props: defaultProps.concat(['autocomplete', 'trim', 'choices'])
|
||||
props: defaultProps.concat(['autocomplete', 'trim', 'choices']),
|
||||
},
|
||||
{
|
||||
types: ['email', 'url', 'date', 'time', 'color'],
|
||||
name: 'InputItem',
|
||||
props: defaultProps.concat(['type', 'trim'])
|
||||
props: defaultProps.concat(['type', 'trim']),
|
||||
},
|
||||
{
|
||||
types: ['password'],
|
||||
|
@ -161,7 +161,7 @@ export function formatYunoHostArgument (arg) {
|
|||
}
|
||||
arg.example = '••••••••••••'
|
||||
validation.passwordLenght = validators.minLength(8)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ['number', 'range'],
|
||||
|
@ -175,7 +175,7 @@ export function formatYunoHostArgument (arg) {
|
|||
validation.maxValue = validators.maxValue(parseInt(arg.max))
|
||||
}
|
||||
validation.numValue = validators.integer
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ['select', 'user', 'domain', 'app', 'group'],
|
||||
|
@ -183,9 +183,12 @@ export function formatYunoHostArgument (arg) {
|
|||
props: ['id', 'choices'],
|
||||
callback: function () {
|
||||
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'],
|
||||
|
@ -197,26 +200,31 @@ export function formatYunoHostArgument (arg) {
|
|||
file: value ? new File([''], value) : null,
|
||||
content: '',
|
||||
current: !!value,
|
||||
removed: false
|
||||
removed: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ['text'],
|
||||
name: 'TextAreaItem',
|
||||
props: defaultProps
|
||||
props: defaultProps,
|
||||
},
|
||||
{
|
||||
types: ['tags'],
|
||||
name: 'TagsItem',
|
||||
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
|
||||
props: defaultProps.concat([
|
||||
'limit',
|
||||
'placeholder',
|
||||
'options:choices',
|
||||
'tagIcon:icon',
|
||||
]),
|
||||
callback: function () {
|
||||
if (arg.choices && arg.choices.length) {
|
||||
this.name = 'TagsSelectizeItem'
|
||||
Object.assign(field.props.props, {
|
||||
auto: true,
|
||||
itemsName: '',
|
||||
label: arg.placeholder
|
||||
label: arg.placeholder,
|
||||
})
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
|
@ -224,7 +232,7 @@ export function formatYunoHostArgument (arg) {
|
|||
} else if (!value) {
|
||||
value = []
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ['boolean'],
|
||||
|
@ -232,36 +240,40 @@ export function formatYunoHostArgument (arg) {
|
|||
props: ['id', 'choices'],
|
||||
callback: function () {
|
||||
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) {
|
||||
value = ['1', 'yes', 'y', 'true'].includes(String(arg.default).toLowerCase())
|
||||
value = ['1', 'yes', 'y', 'true'].includes(
|
||||
String(arg.default).toLowerCase(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ['alert'],
|
||||
name: 'ReadOnlyAlertItem',
|
||||
props: ['type:style', 'label:ask', 'icon'],
|
||||
renderSelf: true
|
||||
renderSelf: true,
|
||||
},
|
||||
{
|
||||
types: ['markdown'],
|
||||
name: 'MarkdownItem',
|
||||
props: ['label:ask'],
|
||||
renderSelf: true
|
||||
renderSelf: true,
|
||||
},
|
||||
{
|
||||
types: ['display_text'],
|
||||
name: 'DisplayTextItem',
|
||||
props: ['label:ask'],
|
||||
renderSelf: true
|
||||
renderSelf: true,
|
||||
},
|
||||
{
|
||||
types: ['button'],
|
||||
name: 'ButtonItem',
|
||||
props: ['type:style', 'label:ask', 'icon', 'enabled'],
|
||||
renderSelf: true
|
||||
}
|
||||
renderSelf: true,
|
||||
},
|
||||
]
|
||||
|
||||
// Default type management if no one is filled
|
||||
|
@ -273,7 +285,9 @@ export function formatYunoHostArgument (arg) {
|
|||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Callback use for specific behaviour
|
||||
|
@ -290,11 +304,18 @@ export function formatYunoHostArgument (arg) {
|
|||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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) {
|
||||
|
@ -321,7 +342,10 @@ export function formatYunoHostArgument (arg) {
|
|||
|
||||
// Help message
|
||||
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) {
|
||||
|
@ -334,11 +358,10 @@ export function formatYunoHostArgument (arg) {
|
|||
field,
|
||||
// Return null instead of empty object if there's no 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
|
||||
* 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.
|
||||
* @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 fields = {}
|
||||
const validations = {}
|
||||
|
@ -361,28 +384,44 @@ export function formatYunoHostArguments (args, forms) {
|
|||
errors[arg.id] = error
|
||||
|
||||
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') {
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
export function formatYunoHostConfigPanels (data) {
|
||||
export function formatYunoHostConfigPanels(data) {
|
||||
const result = {
|
||||
panels: [],
|
||||
forms: {},
|
||||
validations: {},
|
||||
errors: {}
|
||||
errors: {},
|
||||
}
|
||||
|
||||
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.validations[panelId] = {}
|
||||
result.errors[panelId] = {}
|
||||
|
@ -394,7 +433,7 @@ export function formatYunoHostConfigPanels (data) {
|
|||
const section = {
|
||||
id: _section.id,
|
||||
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.name) section.name = formatI18nField(_section.name)
|
||||
|
@ -402,12 +441,10 @@ export function formatYunoHostConfigPanels (data) {
|
|||
addEvaluationGetter('visible', section, _section.visible, result.forms)
|
||||
}
|
||||
|
||||
const {
|
||||
form,
|
||||
fields,
|
||||
validations,
|
||||
errors
|
||||
} = formatYunoHostArguments(_section.options, result.forms)
|
||||
const { form, fields, validations, errors } = formatYunoHostArguments(
|
||||
_section.options,
|
||||
result.forms,
|
||||
)
|
||||
// Merge all sections forms to the panel to get a unique form
|
||||
Object.assign(result.forms[panelId], form)
|
||||
Object.assign(result.validations[panelId], validations)
|
||||
|
@ -415,7 +452,12 @@ export function formatYunoHostConfigPanels (data) {
|
|||
section.fields = fields
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -426,7 +468,6 @@ export function formatYunoHostConfigPanels (data) {
|
|||
return result
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -439,11 +480,11 @@ export function formatYunoHostConfigPanels (data) {
|
|||
* @param {*} value
|
||||
* @return {*}
|
||||
*/
|
||||
export function formatFormDataValue (value, key = null) {
|
||||
export function formatFormDataValue(value, key = null) {
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(
|
||||
value.map(value_ => formatFormDataValue(value_))
|
||||
).then(resolvedValues => ({ [key]: resolvedValues }))
|
||||
return Promise.all(value.map((value_) => formatFormDataValue(value_))).then(
|
||||
(resolvedValues) => ({ [key]: resolvedValues }),
|
||||
)
|
||||
}
|
||||
|
||||
let result = value
|
||||
|
@ -454,10 +495,10 @@ export function formatFormDataValue (value, key = null) {
|
|||
// File has not changed (will not be sent)
|
||||
else if (value.current || value.file === null) result = null
|
||||
else {
|
||||
return getFileContent(value.file, { base64: true }).then(content => {
|
||||
return getFileContent(value.file, { base64: true }).then((content) => {
|
||||
return {
|
||||
[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)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -478,17 +518,16 @@ export function formatFormDataValue (value, key = null) {
|
|||
* @param {Object} formData
|
||||
* @return {Object}
|
||||
*/
|
||||
function formatFormDataValues (formData) {
|
||||
function formatFormDataValues(formData) {
|
||||
const promisedValues = Object.entries(formData).map(([key, value]) => {
|
||||
return formatFormDataValue(value, key)
|
||||
})
|
||||
|
||||
return Promise.all(promisedValues).then(resolvedValues => {
|
||||
return Promise.all(promisedValues).then((resolvedValues) => {
|
||||
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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,
|
||||
{ extract = null, flatten = false, removeEmpty = true, removeNull = false } = {}
|
||||
{
|
||||
extract = null,
|
||||
flatten = false,
|
||||
removeEmpty = true,
|
||||
removeNull = false,
|
||||
} = {},
|
||||
) {
|
||||
const output = {
|
||||
data: {},
|
||||
extracted: {}
|
||||
extracted: {},
|
||||
}
|
||||
|
||||
const values = await formatFormDataValues(formData)
|
||||
|
|
|
@ -11,7 +11,7 @@ const loadedLanguages = []
|
|||
*
|
||||
* @return {string[]}
|
||||
*/
|
||||
function getDefaultLocales () {
|
||||
function getDefaultLocales() {
|
||||
const locale = store.getters.locale
|
||||
const fallbackLocale = store.getters.fallbackLocale
|
||||
if (locale && fallbackLocale) return [locale, fallbackLocale]
|
||||
|
@ -34,7 +34,7 @@ function getDefaultLocales () {
|
|||
return defaultLocales
|
||||
}
|
||||
|
||||
function updateDocumentLocale (locale) {
|
||||
function updateDocumentLocale(locale) {
|
||||
document.documentElement.lang = locale
|
||||
// FIXME can't currently change document direction easily since bootstrap still doesn't handle rtl.
|
||||
// document.dir = locale === 'ar' ? 'rtl' : 'ltr'
|
||||
|
@ -45,11 +45,11 @@ function updateDocumentLocale (locale) {
|
|||
*
|
||||
* @return {Promise<string>} Promise that resolve the given locale string
|
||||
*/
|
||||
function loadLocaleMessages (locale) {
|
||||
function loadLocaleMessages(locale) {
|
||||
if (loadedLanguages.includes(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)
|
||||
loadedLanguages.push(locale)
|
||||
return locale
|
||||
|
@ -59,17 +59,19 @@ function loadLocaleMessages (locale) {
|
|||
/**
|
||||
* Loads a date-fns locale object
|
||||
*/
|
||||
async function loadDateFnsLocale (locale) {
|
||||
async function loadDateFnsLocale(locale) {
|
||||
const dateFnsLocaleName = supportedLocales[locale].dateFnsLocale || locale
|
||||
dateFnsLocale = (await import(
|
||||
`../../node_modules/date-fns/esm/locale/${dateFnsLocaleName}/index.js`
|
||||
)).default
|
||||
dateFnsLocale = (
|
||||
await import(
|
||||
`../../node_modules/date-fns/esm/locale/${dateFnsLocaleName}/index.js`
|
||||
)
|
||||
).default
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all locales
|
||||
*/
|
||||
function initDefaultLocales () {
|
||||
function initDefaultLocales() {
|
||||
// Get defined locales from `localStorage` or `navigator`
|
||||
const [locale, fallbackLocale] = getDefaultLocales()
|
||||
|
||||
|
@ -83,5 +85,5 @@ export {
|
|||
updateDocumentLocale,
|
||||
loadLocaleMessages,
|
||||
loadDateFnsLocale,
|
||||
dateFnsLocale
|
||||
dateFnsLocale,
|
||||
}
|
||||
|
|
|
@ -6,135 +6,135 @@
|
|||
|
||||
export default {
|
||||
ar: {
|
||||
name: 'عربي'
|
||||
name: 'عربي',
|
||||
},
|
||||
bn_BD: {
|
||||
name: 'বাংলা',
|
||||
dateFnsLocale: 'bn'
|
||||
dateFnsLocale: 'bn',
|
||||
},
|
||||
br: {
|
||||
name: 'Brezhoneg',
|
||||
dateFnsLocale: 'fr'
|
||||
dateFnsLocale: 'fr',
|
||||
},
|
||||
ca: {
|
||||
name: 'Català'
|
||||
name: 'Català',
|
||||
},
|
||||
ckb: {
|
||||
name: 'کوردی',
|
||||
dateFnsLocale: 'fa-IR'
|
||||
dateFnsLocale: 'fa-IR',
|
||||
// FIXME fallback to Farsi (`fa-IR`) is arbitrary, some would probably prefer Arabic (`ar`)...
|
||||
},
|
||||
cs: {
|
||||
name: 'Čeština'
|
||||
name: 'Čeština',
|
||||
},
|
||||
da: {
|
||||
name: 'Dansk'
|
||||
name: 'Dansk',
|
||||
},
|
||||
de: {
|
||||
name: 'Deutsch'
|
||||
name: 'Deutsch',
|
||||
},
|
||||
el: {
|
||||
name: 'Eλληνικά'
|
||||
name: 'Eλληνικά',
|
||||
},
|
||||
en: {
|
||||
name: 'English',
|
||||
dateFnsLocale: 'en-GB'
|
||||
dateFnsLocale: 'en-GB',
|
||||
},
|
||||
eo: {
|
||||
name: 'Esperanto'
|
||||
name: 'Esperanto',
|
||||
},
|
||||
es: {
|
||||
name: 'Español'
|
||||
name: 'Español',
|
||||
},
|
||||
eu: {
|
||||
name: 'Euskara'
|
||||
name: 'Euskara',
|
||||
},
|
||||
fa: {
|
||||
name: 'فارسی',
|
||||
dateFnsLocale: 'fa-IR'
|
||||
dateFnsLocale: 'fa-IR',
|
||||
},
|
||||
fi: {
|
||||
name: 'Suomi'
|
||||
name: 'Suomi',
|
||||
},
|
||||
fr: {
|
||||
name: 'Français'
|
||||
name: 'Français',
|
||||
},
|
||||
gl: {
|
||||
name: 'Galego'
|
||||
name: 'Galego',
|
||||
},
|
||||
he: {
|
||||
name: 'עברית'
|
||||
name: 'עברית',
|
||||
},
|
||||
hi: {
|
||||
name: 'हिन्दी'
|
||||
name: 'हिन्दी',
|
||||
},
|
||||
hu: {
|
||||
name: 'Magyar'
|
||||
name: 'Magyar',
|
||||
},
|
||||
id: {
|
||||
name: 'Bahasa Indonesia'
|
||||
name: 'Bahasa Indonesia',
|
||||
},
|
||||
it: {
|
||||
name: 'Italiano'
|
||||
name: 'Italiano',
|
||||
},
|
||||
kab: {
|
||||
name: 'Taqbaylit',
|
||||
dateFnsLocale: 'ar-DZ'
|
||||
dateFnsLocale: 'ar-DZ',
|
||||
},
|
||||
lt: {
|
||||
name: 'Lietuvių'
|
||||
name: 'Lietuvių',
|
||||
},
|
||||
mk: {
|
||||
name: 'македонски'
|
||||
name: 'македонски',
|
||||
},
|
||||
nb_NO: {
|
||||
name: 'Norsk bokmål',
|
||||
dateFnsLocale: 'nb'
|
||||
dateFnsLocale: 'nb',
|
||||
},
|
||||
ne: {
|
||||
name: 'नेपाली',
|
||||
dateFnsLocale: 'en-GB'
|
||||
dateFnsLocale: 'en-GB',
|
||||
},
|
||||
nl: {
|
||||
name: 'Nederlands'
|
||||
name: 'Nederlands',
|
||||
},
|
||||
oc: {
|
||||
name: 'Occitan',
|
||||
dateFnsLocale: 'ca'
|
||||
dateFnsLocale: 'ca',
|
||||
},
|
||||
pl: {
|
||||
name: 'Polski'
|
||||
name: 'Polski',
|
||||
},
|
||||
pt: {
|
||||
name: 'Português'
|
||||
name: 'Português',
|
||||
},
|
||||
pt_BR: {
|
||||
name: 'Português brasileiro',
|
||||
dateFnsLocale: 'pt-BR'
|
||||
dateFnsLocale: 'pt-BR',
|
||||
},
|
||||
ru: {
|
||||
name: 'Русский'
|
||||
name: 'Русский',
|
||||
},
|
||||
sk: {
|
||||
name: 'Slovak'
|
||||
name: 'Slovak',
|
||||
},
|
||||
sl: {
|
||||
name: 'Slovenščina'
|
||||
name: 'Slovenščina',
|
||||
},
|
||||
sv: {
|
||||
name: 'Svenska'
|
||||
name: 'Svenska',
|
||||
},
|
||||
te: {
|
||||
name: 'Telugu'
|
||||
name: 'Telugu',
|
||||
},
|
||||
tr: {
|
||||
name: 'Türkçe'
|
||||
name: 'Türkçe',
|
||||
},
|
||||
uk: {
|
||||
name: 'Українська'
|
||||
name: 'Українська',
|
||||
},
|
||||
zh_Hans: {
|
||||
name: '简化字',
|
||||
dateFnsLocale: 'zh-CN'
|
||||
}
|
||||
dateFnsLocale: 'zh-CN',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -10,20 +10,19 @@ import i18n from './i18n'
|
|||
import { registerGlobalErrorHandlers } from './api'
|
||||
import { initDefaultLocales } from './i18n/helpers'
|
||||
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// Styles are imported in `src/App.vue` <style>
|
||||
Vue.use(BootstrapVue, {
|
||||
BSkeleton: { animation: 'none' },
|
||||
BAlert: { show: true },
|
||||
BBadge: { pill: true }
|
||||
BBadge: { pill: true },
|
||||
})
|
||||
|
||||
Vue.use(VueShowdown, {
|
||||
options: {
|
||||
emoji: true
|
||||
}
|
||||
emoji: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 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'),
|
||||
bodyBgVariant: 'warning',
|
||||
centered: true,
|
||||
bodyClass: ['font-weight-bold', 'rounded-top', store.state.theme ? 'text-white' : 'text-black'],
|
||||
...props
|
||||
bodyClass: [
|
||||
'font-weight-bold',
|
||||
'rounded-top',
|
||||
store.state.theme ? 'text-white' : 'text-black',
|
||||
],
|
||||
...props,
|
||||
})
|
||||
}
|
||||
|
||||
Vue.prototype.$askMdConfirmation = function (markdown, props, ok = false) {
|
||||
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, {
|
||||
okTitle: this.$i18n.t('yes'),
|
||||
|
@ -49,15 +52,15 @@ Vue.prototype.$askMdConfirmation = function (markdown, props, ok = false) {
|
|||
headerBgVariant: 'warning',
|
||||
headerClass: store.state.theme ? 'text-white' : 'text-black',
|
||||
centered: true,
|
||||
...props
|
||||
...props,
|
||||
})
|
||||
}
|
||||
|
||||
// Register global components
|
||||
const globalComponentsModules = import.meta.glob([
|
||||
'@/components/globals/*.vue',
|
||||
'@/components/globals/*/*.vue'
|
||||
], { eager: true })
|
||||
const globalComponentsModules = import.meta.glob(
|
||||
['@/components/globals/*.vue', '@/components/globals/*/*.vue'],
|
||||
{ eager: true },
|
||||
)
|
||||
Object.values(globalComponentsModules).forEach((module) => {
|
||||
const component = module.default
|
||||
Vue.component(component.name, component)
|
||||
|
@ -71,7 +74,7 @@ initDefaultLocales().then(() => {
|
|||
store,
|
||||
router,
|
||||
i18n,
|
||||
render: h => h(App)
|
||||
render: (h) => h(App),
|
||||
})
|
||||
|
||||
app.$mount('#app')
|
||||
|
|
|
@ -10,7 +10,7 @@ const router = new VueRouter({
|
|||
base: import.meta.env.BASE_URL,
|
||||
routes,
|
||||
|
||||
scrollBehavior (to, from, savedPosition) {
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// 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.
|
||||
|
||||
|
@ -18,13 +18,13 @@ const router = new VueRouter({
|
|||
// 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
|
||||
if (store.getters.transitions && savedPosition) {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(savedPosition), 0)
|
||||
})
|
||||
} else {
|
||||
return savedPosition || { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
|
|
|
@ -18,8 +18,8 @@ const routes = [
|
|||
path: '/',
|
||||
component: HomeView,
|
||||
meta: {
|
||||
args: { trad: 'home' }
|
||||
}
|
||||
args: { trad: 'home' },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
|
@ -28,8 +28,8 @@ const routes = [
|
|||
component: LoginView,
|
||||
meta: {
|
||||
noAuth: true,
|
||||
args: { trad: 'login' }
|
||||
}
|
||||
args: { trad: 'login' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ───────────────╮
|
||||
|
@ -41,8 +41,8 @@ const routes = [
|
|||
component: () => import('@/views/PostInstall.vue'),
|
||||
meta: {
|
||||
noAuth: true,
|
||||
args: { trad: 'postinstall.title' }
|
||||
}
|
||||
args: { trad: 'postinstall.title' },
|
||||
},
|
||||
},
|
||||
|
||||
/* ───────╮
|
||||
|
@ -54,8 +54,8 @@ const routes = [
|
|||
component: () => import('@/views/user/UserList.vue'),
|
||||
meta: {
|
||||
args: { trad: 'users' },
|
||||
breadcrumb: ['user-list']
|
||||
}
|
||||
breadcrumb: ['user-list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user-create',
|
||||
|
@ -63,8 +63,8 @@ const routes = [
|
|||
component: () => import('@/views/user/UserCreate.vue'),
|
||||
meta: {
|
||||
args: { trad: 'users_new' },
|
||||
breadcrumb: ['user-list', 'user-create']
|
||||
}
|
||||
breadcrumb: ['user-list', 'user-create'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user-import',
|
||||
|
@ -73,8 +73,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
args: { trad: 'users_import' },
|
||||
breadcrumb: ['user-list', 'user-import']
|
||||
}
|
||||
breadcrumb: ['user-list', 'user-import'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user-info',
|
||||
|
@ -83,8 +83,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
args: { param: 'name' },
|
||||
breadcrumb: ['user-list', 'user-info']
|
||||
}
|
||||
breadcrumb: ['user-list', 'user-info'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user-edit',
|
||||
|
@ -93,8 +93,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
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'),
|
||||
meta: {
|
||||
args: { trad: 'groups_and_permissions' },
|
||||
breadcrumb: ['user-list', 'group-list']
|
||||
}
|
||||
breadcrumb: ['user-list', 'group-list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group-create',
|
||||
|
@ -115,8 +115,8 @@ const routes = [
|
|||
component: () => import('@/views/group/GroupCreate.vue'),
|
||||
meta: {
|
||||
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'),
|
||||
meta: {
|
||||
args: { trad: 'domains' },
|
||||
breadcrumb: ['domain-list']
|
||||
}
|
||||
breadcrumb: ['domain-list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'domain-add',
|
||||
|
@ -137,8 +137,8 @@ const routes = [
|
|||
component: () => import('@/views/domain/DomainAdd.vue'),
|
||||
meta: {
|
||||
args: { trad: 'domain_add' },
|
||||
breadcrumb: ['domain-list', 'domain-add']
|
||||
}
|
||||
breadcrumb: ['domain-list', 'domain-add'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/domains/:name',
|
||||
|
@ -153,10 +153,10 @@ const routes = [
|
|||
meta: {
|
||||
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
|
||||
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'),
|
||||
meta: {
|
||||
args: { trad: 'applications' },
|
||||
breadcrumb: ['app-list']
|
||||
}
|
||||
breadcrumb: ['app-list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'app-catalog',
|
||||
path: '/apps/catalog',
|
||||
component: () => import('@/views/app/AppCatalog.vue'),
|
||||
props: route => route.query,
|
||||
props: (route) => route.query,
|
||||
meta: {
|
||||
args: { trad: 'catalog' },
|
||||
breadcrumb: ['app-list', 'app-catalog']
|
||||
}
|
||||
breadcrumb: ['app-list', 'app-catalog'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'app-install',
|
||||
|
@ -188,8 +188,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
args: { trad: 'install_name', param: 'id' },
|
||||
breadcrumb: ['app-list', 'app-catalog', 'app-install']
|
||||
}
|
||||
breadcrumb: ['app-list', 'app-catalog', 'app-install'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'app-install-custom',
|
||||
|
@ -198,8 +198,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
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',
|
||||
|
@ -214,10 +214,10 @@ const routes = [
|
|||
meta: {
|
||||
routerParams: ['id'], // Override router key params to avoid view recreation at tab change.
|
||||
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'),
|
||||
meta: {
|
||||
args: { trad: 'system_update' },
|
||||
breadcrumb: ['update']
|
||||
}
|
||||
breadcrumb: ['update'],
|
||||
},
|
||||
},
|
||||
|
||||
/* ──────────╮
|
||||
|
@ -242,8 +242,8 @@ const routes = [
|
|||
component: () => import('@/views/service/ServiceList.vue'),
|
||||
meta: {
|
||||
args: { trad: 'services' },
|
||||
breadcrumb: ['tool-list', 'service-list']
|
||||
}
|
||||
breadcrumb: ['tool-list', 'service-list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'service-info',
|
||||
|
@ -252,8 +252,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
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,
|
||||
meta: {
|
||||
args: { trad: 'tools' },
|
||||
breadcrumb: ['tool-list']
|
||||
}
|
||||
breadcrumb: ['tool-list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tool-logs',
|
||||
|
@ -274,8 +274,8 @@ const routes = [
|
|||
component: () => import('@/views/tool/ToolLogs.vue'),
|
||||
meta: {
|
||||
args: { trad: 'logs' },
|
||||
breadcrumb: ['tool-list', 'tool-logs']
|
||||
}
|
||||
breadcrumb: ['tool-list', 'tool-logs'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tool-log',
|
||||
|
@ -284,8 +284,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
args: { param: 'name' },
|
||||
breadcrumb: ['tool-list', 'tool-logs', 'tool-log']
|
||||
}
|
||||
breadcrumb: ['tool-list', 'tool-logs', 'tool-log'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tool-migrations',
|
||||
|
@ -293,8 +293,8 @@ const routes = [
|
|||
component: () => import('@/views/tool/ToolMigrations.vue'),
|
||||
meta: {
|
||||
args: { trad: 'migrations' },
|
||||
breadcrumb: ['tool-list', 'tool-migrations']
|
||||
}
|
||||
breadcrumb: ['tool-list', 'tool-migrations'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tool-firewall',
|
||||
|
@ -302,8 +302,8 @@ const routes = [
|
|||
component: () => import('@/views/tool/ToolFirewall.vue'),
|
||||
meta: {
|
||||
args: { trad: 'firewall' },
|
||||
breadcrumb: ['tool-list', 'tool-firewall']
|
||||
}
|
||||
breadcrumb: ['tool-list', 'tool-firewall'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tool-webadmin',
|
||||
|
@ -311,8 +311,8 @@ const routes = [
|
|||
component: () => import('@/views/tool/ToolWebadmin.vue'),
|
||||
meta: {
|
||||
args: { trad: 'tools_webadmin_settings' },
|
||||
breadcrumb: ['tool-list', 'tool-webadmin']
|
||||
}
|
||||
breadcrumb: ['tool-list', 'tool-webadmin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/tools/settings',
|
||||
|
@ -326,10 +326,10 @@ const routes = [
|
|||
meta: {
|
||||
routerParams: [],
|
||||
args: { trad: 'tools_yunohost_settings' },
|
||||
breadcrumb: ['tool-list', 'tool-settings']
|
||||
}
|
||||
}
|
||||
]
|
||||
breadcrumb: ['tool-list', 'tool-settings'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tool-power',
|
||||
|
@ -337,8 +337,8 @@ const routes = [
|
|||
component: () => import('@/views/tool/ToolPower.vue'),
|
||||
meta: {
|
||||
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'),
|
||||
meta: {
|
||||
args: { trad: 'diagnosis' },
|
||||
breadcrumb: ['diagnosis']
|
||||
}
|
||||
breadcrumb: ['diagnosis'],
|
||||
},
|
||||
},
|
||||
|
||||
/* ─────────╮
|
||||
|
@ -363,8 +363,8 @@ const routes = [
|
|||
component: () => import('@/views/backup/BackupView.vue'),
|
||||
meta: {
|
||||
args: { trad: 'backup' },
|
||||
breadcrumb: ['backup']
|
||||
}
|
||||
breadcrumb: ['backup'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'backup-list',
|
||||
|
@ -373,8 +373,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
args: { param: 'id' },
|
||||
breadcrumb: ['backup', 'backup-list']
|
||||
}
|
||||
breadcrumb: ['backup', 'backup-list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'backup-info',
|
||||
|
@ -383,8 +383,8 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
args: { param: 'name' },
|
||||
breadcrumb: ['backup', 'backup-list', 'backup-info']
|
||||
}
|
||||
breadcrumb: ['backup', 'backup-list', 'backup-info'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'backup-create',
|
||||
|
@ -393,9 +393,9 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
args: { trad: 'backup_create' },
|
||||
breadcrumb: ['backup', 'backup-list', 'backup-create']
|
||||
}
|
||||
}
|
||||
breadcrumb: ['backup', 'backup-list', 'backup-create'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
|
|
|
@ -34,11 +34,13 @@
|
|||
0: h,
|
||||
1: s,
|
||||
2: l,
|
||||
3: a
|
||||
3: a,
|
||||
);
|
||||
// find end of part
|
||||
$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), ',');
|
||||
@if (not $newEnd) {
|
||||
$newEnd: 0;
|
||||
|
@ -49,7 +51,7 @@
|
|||
$part: str-slice($color, 0, $end - 1);
|
||||
$value: map-merge(
|
||||
(
|
||||
map-get($indices, $index): $part
|
||||
map-get($indices, $index): $part,
|
||||
),
|
||||
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
|
||||
@function theme-color-level($color-name: "primary", $level: 0) {
|
||||
@function theme-color-level($color-name: 'primary', $level: 0) {
|
||||
$color: theme-color($color-name);
|
||||
@if ($level == 0) {
|
||||
@return $color;
|
||||
}
|
||||
|
||||
$amount: math.div($theme-color-interval * abs($level) , 100%);
|
||||
$amount: math.div($theme-color-interval * abs($level), 100%);
|
||||
$c: to-hsl($color);
|
||||
$h: map-get($c, h);
|
||||
$s: map-get($c, s);
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
// ╭─────────────────────────────────────────────────────────────────╮
|
||||
// │ │
|
||||
// │ /!\ 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
|
||||
// times as there are components…
|
||||
|
||||
|
||||
// ╭─────────────────────────────╮
|
||||
// │ ┌─╮╭─╮╭─╮╶┬╴╭─╴╶┬╴┌─╮╭─┐┌─╮ │
|
||||
// │ │╶┤│ ││ │ │ ╰─╮ │ ├┬╯├─┤├─╯ │
|
||||
|
@ -27,10 +25,10 @@
|
|||
// For exemple, turning rounding of elements off, the bases colors, etc.
|
||||
// $enable-rounded: false;
|
||||
|
||||
$font-size-base: .9rem;
|
||||
$font-size-base: 0.9rem;
|
||||
$font-weight-bold: 500;
|
||||
|
||||
$white: var(--white);
|
||||
$white: var(--white);
|
||||
$gray-100: var(--gray-100);
|
||||
$gray-200: var(--gray-200);
|
||||
$gray-300: var(--gray-300);
|
||||
|
@ -40,18 +38,18 @@ $gray-600: var(--gray-600);
|
|||
$gray-700: var(--gray-700);
|
||||
$gray-800: var(--gray-800);
|
||||
$gray-900: var(--gray-900);
|
||||
$black: var(--black);
|
||||
$black: var(--black);
|
||||
|
||||
$blue: var(--blue);
|
||||
$indigo: var(--indigo);
|
||||
$purple: var(--purple);
|
||||
$pink: var(--pink);
|
||||
$red: var(--red);
|
||||
$orange: var(--orange);
|
||||
$yellow: var(--yellow);
|
||||
$green: var(--green);
|
||||
$teal: var(--teal);
|
||||
$cyan: var(--cyan);
|
||||
$blue: var(--blue);
|
||||
$indigo: var(--indigo);
|
||||
$purple: var(--purple);
|
||||
$pink: var(--pink);
|
||||
$red: var(--red);
|
||||
$orange: var(--orange);
|
||||
$yellow: var(--yellow);
|
||||
$green: var(--green);
|
||||
$teal: var(--teal);
|
||||
$cyan: var(--cyan);
|
||||
|
||||
$theme-colors: (
|
||||
'best': $purple,
|
||||
|
@ -59,12 +57,12 @@ $theme-colors: (
|
|||
|
||||
$yiq-contrasted-threshold: var(--yiq-contrasted-threshold);
|
||||
|
||||
$alert-bg-level: -10;
|
||||
$alert-bg-level: -10;
|
||||
$alert-border-level: -9;
|
||||
$alert-color-level: 5;
|
||||
$alert-color-level: 5;
|
||||
|
||||
$list-group-item-bg-level: -11;
|
||||
$list-group-item-color-level: 6;
|
||||
$list-group-item-bg-level: -11;
|
||||
$list-group-item-color-level: 6;
|
||||
|
||||
$code-color: $black;
|
||||
|
||||
|
@ -77,8 +75,23 @@ $display4-weight: 200;
|
|||
$lead-font-weight: 200;
|
||||
|
||||
// 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-monospace: 'Fira Code', SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !default;
|
||||
$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-monospace: 'Fira Code', SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace !default;
|
||||
|
||||
$h2-font-size: $font-size-base * 1.5;
|
||||
$h3-font-size: $font-size-base * 1.4;
|
||||
|
@ -87,7 +100,7 @@ $h5-font-size: $font-size-base * 1.1;
|
|||
|
||||
$alert-padding-x: 1rem;
|
||||
|
||||
$card-spacer-y: .6rem;
|
||||
$card-spacer-y: 0.6rem;
|
||||
$card-spacer-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-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 '~bootstrap/scss/functions.scss';
|
||||
|
@ -114,7 +129,6 @@ $custom-checkbox-indicator-icon-indeterminate: get-checkbox-icon-indeterminate('
|
|||
@import '~bootstrap/scss/mixins.scss';
|
||||
@import '~bootstrap-vue/src/variables';
|
||||
|
||||
|
||||
$hr-border-color: $gray-200;
|
||||
|
||||
$list-group-action-color: $gray-800;
|
||||
|
@ -133,7 +147,6 @@ $fa-font-size-base: $font-size-base;
|
|||
|
||||
@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;
|
||||
|
||||
$btn-padding-y-xs: .25rem;
|
||||
$btn-padding-x-xs: .35rem;
|
||||
$btn-padding-y-xs: 0.25rem;
|
||||
$btn-padding-x-xs: 0.35rem;
|
||||
$btn-line-height-xs: 1.5;
|
||||
|
|
|
@ -108,7 +108,8 @@
|
|||
src:
|
||||
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.woff') format('woff');
|
||||
url('~@fontsource/fira-code/files/fira-code-all-400-normal.woff')
|
||||
format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
|
|
@ -5,42 +5,41 @@
|
|||
// Variables overrides are defined before actual SCSS imports
|
||||
@import 'variables';
|
||||
|
||||
|
||||
// Dependencies SCSS imports
|
||||
// `~` allow to import a node_modules folder (resolved by Webpack)
|
||||
// @import "~bootstrap/scss/root";
|
||||
@import "~bootstrap/scss/reboot";
|
||||
@import "~bootstrap/scss/type";
|
||||
@import "~bootstrap/scss/images";
|
||||
@import "~bootstrap/scss/code";
|
||||
@import "~bootstrap/scss/grid";
|
||||
@import "~bootstrap/scss/tables";
|
||||
@import "~bootstrap/scss/forms";
|
||||
@import "~bootstrap/scss/buttons";
|
||||
@import "~bootstrap/scss/transitions";
|
||||
@import "~bootstrap/scss/dropdown";
|
||||
@import "~bootstrap/scss/button-group";
|
||||
@import "~bootstrap/scss/input-group";
|
||||
@import "~bootstrap/scss/custom-forms";
|
||||
@import "~bootstrap/scss/nav";
|
||||
@import "~bootstrap/scss/navbar";
|
||||
@import "~bootstrap/scss/card";
|
||||
@import "~bootstrap/scss/breadcrumb";
|
||||
@import '~bootstrap/scss/reboot';
|
||||
@import '~bootstrap/scss/type';
|
||||
@import '~bootstrap/scss/images';
|
||||
@import '~bootstrap/scss/code';
|
||||
@import '~bootstrap/scss/grid';
|
||||
@import '~bootstrap/scss/tables';
|
||||
@import '~bootstrap/scss/forms';
|
||||
@import '~bootstrap/scss/buttons';
|
||||
@import '~bootstrap/scss/transitions';
|
||||
@import '~bootstrap/scss/dropdown';
|
||||
@import '~bootstrap/scss/button-group';
|
||||
@import '~bootstrap/scss/input-group';
|
||||
@import '~bootstrap/scss/custom-forms';
|
||||
@import '~bootstrap/scss/nav';
|
||||
@import '~bootstrap/scss/navbar';
|
||||
@import '~bootstrap/scss/card';
|
||||
@import '~bootstrap/scss/breadcrumb';
|
||||
// @import "~bootstrap/scss/pagination";
|
||||
@import "~bootstrap/scss/badge";
|
||||
@import '~bootstrap/scss/badge';
|
||||
// @import "~bootstrap/scss/jumbotron";
|
||||
@import "~bootstrap/scss/alert";
|
||||
@import "~bootstrap/scss/progress";
|
||||
@import '~bootstrap/scss/alert';
|
||||
@import '~bootstrap/scss/progress';
|
||||
// @import "~bootstrap/scss/media";
|
||||
@import "~bootstrap/scss/list-group";
|
||||
@import "~bootstrap/scss/close";
|
||||
@import '~bootstrap/scss/list-group';
|
||||
@import '~bootstrap/scss/close';
|
||||
// @import "~bootstrap/scss/toasts";
|
||||
@import "~bootstrap/scss/modal";
|
||||
@import "~bootstrap/scss/tooltip";
|
||||
@import "~bootstrap/scss/popover";
|
||||
@import '~bootstrap/scss/modal';
|
||||
@import '~bootstrap/scss/tooltip';
|
||||
@import '~bootstrap/scss/popover';
|
||||
// @import "~bootstrap/scss/carousel";
|
||||
@import "~bootstrap/scss/spinners";
|
||||
@import "~bootstrap/scss/utilities";
|
||||
@import '~bootstrap/scss/spinners';
|
||||
@import '~bootstrap/scss/utilities';
|
||||
// @import "~bootstrap/scss/print";
|
||||
|
||||
@import '~bootstrap-vue/src/index.scss';
|
||||
|
@ -87,18 +86,22 @@
|
|||
|
||||
// Overwrite list-group-item variants to lighter ones (used in diagnosis for example)
|
||||
@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} {
|
||||
&: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
|
||||
|
||||
--yiq-contrasted-threshold: 120;
|
||||
|
@ -122,10 +125,18 @@
|
|||
@include hsl-color('gray-100', 256, 0%, 15%);
|
||||
|
||||
@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} {
|
||||
@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;
|
||||
}
|
||||
|
||||
|
||||
// Add breakpoints for w-*
|
||||
@each $breakpoint in map-keys($grid-breakpoints) {
|
||||
@each $size, $length in $sizes {
|
||||
|
@ -178,7 +188,13 @@ body {
|
|||
|
||||
// Add xs sized btn
|
||||
.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
|
||||
|
@ -186,8 +202,9 @@ body {
|
|||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.tooltip { top: 0; }
|
||||
.tooltip {
|
||||
top: 0;
|
||||
}
|
||||
// Descriptive list (<b-row /> elems with <b-col> inside)
|
||||
// FIXME REMOVE when every infos switch to `DescriptionRow`
|
||||
.row-line {
|
||||
|
@ -199,31 +216,45 @@ body {
|
|||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: $border-width solid $card-border-color;
|
||||
}
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
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;
|
||||
}
|
||||
.card-deck .card + .card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card-header, .list-group-item {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
.card-header,
|
||||
.list-group-item {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header, .list-group-item {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
.card-header,
|
||||
.list-group-item {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: $font-weight-normal;
|
||||
}
|
||||
}
|
||||
|
@ -275,14 +306,14 @@ h3.card-title {
|
|||
justify-content: space-between;
|
||||
|
||||
.btn {
|
||||
margin-bottom: .5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
.btn ~ .btn {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.btn ~ .dropdown-toggle-split {
|
||||
margin-left: 0;
|
||||
|
@ -302,7 +333,7 @@ h3.card-title {
|
|||
|
||||
code {
|
||||
background: $gray-300;
|
||||
padding: .15rem .25rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,7 @@ import api from '@/api'
|
|||
import { isEmptyValue } from '@/helpers/commons'
|
||||
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'
|
||||
let i = domain[method]('.')
|
||||
while (i !== -1) {
|
||||
|
@ -17,7 +16,6 @@ export function getParentDomain (domain, domains, highest = false) {
|
|||
return null
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
state: () => ({
|
||||
main_domain: undefined,
|
||||
|
@ -26,36 +24,36 @@ export default {
|
|||
users: undefined, // basic user data: Object {username: {data}}
|
||||
users_details: {}, // precise user data: Object {username: {data}}
|
||||
groups: undefined,
|
||||
permissions: undefined
|
||||
permissions: undefined,
|
||||
}),
|
||||
|
||||
mutations: {
|
||||
'SET_DOMAINS' (state, [{ domains, main }]) {
|
||||
SET_DOMAINS(state, [{ domains, main }]) {
|
||||
state.domains = domains
|
||||
state.main_domain = main
|
||||
},
|
||||
|
||||
'SET_DOMAINS_DETAILS' (state, [name, details]) {
|
||||
SET_DOMAINS_DETAILS(state, [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 ?
|
||||
this.commit('SET_DOMAINS_DETAILS', payload)
|
||||
},
|
||||
|
||||
'DEL_DOMAINS_DETAILS' (state, [name]) {
|
||||
DEL_DOMAINS_DETAILS(state, [name]) {
|
||||
Vue.delete(state.domains_details, name)
|
||||
if (state.domains) {
|
||||
Vue.delete(state.domains, name)
|
||||
}
|
||||
},
|
||||
|
||||
'ADD_DOMAINS' (state, [{ domain }]) {
|
||||
ADD_DOMAINS(state, [{ domain }]) {
|
||||
state.domains.push(domain)
|
||||
},
|
||||
|
||||
'DEL_DOMAINS' (state, [domain]) {
|
||||
DEL_DOMAINS(state, [domain]) {
|
||||
state.domains.splice(state.domains.indexOf(domain), 1)
|
||||
},
|
||||
|
||||
|
@ -64,20 +62,20 @@ export default {
|
|||
// state.main_domain = response.current_main_domain
|
||||
// },
|
||||
|
||||
'UPDATE_MAIN_DOMAIN' (state, [domain]) {
|
||||
UPDATE_MAIN_DOMAIN(state, [domain]) {
|
||||
state.main_domain = domain
|
||||
},
|
||||
|
||||
'SET_USERS' (state, [users]) {
|
||||
SET_USERS(state, [users]) {
|
||||
state.users = users || null
|
||||
},
|
||||
|
||||
'ADD_USERS' (state, [user]) {
|
||||
ADD_USERS(state, [user]) {
|
||||
if (!state.users) state.users = {}
|
||||
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)
|
||||
if (!state.users) return
|
||||
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 ?
|
||||
this.commit('SET_USERS_DETAILS', payload)
|
||||
},
|
||||
|
||||
'DEL_USERS_DETAILS' (state, [username]) {
|
||||
DEL_USERS_DETAILS(state, [username]) {
|
||||
Vue.delete(state.users_details, username)
|
||||
if (state.users) {
|
||||
Vue.delete(state.users, username)
|
||||
|
@ -103,29 +101,29 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
'SET_GROUPS' (state, [groups]) {
|
||||
SET_GROUPS(state, [groups]) {
|
||||
state.groups = groups
|
||||
},
|
||||
|
||||
'ADD_GROUPS' (state, [{ name }]) {
|
||||
ADD_GROUPS(state, [{ name }]) {
|
||||
if (state.groups !== undefined) {
|
||||
Vue.set(state.groups, name, { members: [], permissions: [] })
|
||||
}
|
||||
},
|
||||
|
||||
'UPDATE_GROUPS' (state, [data, { groupName }]) {
|
||||
UPDATE_GROUPS(state, [data, { groupName }]) {
|
||||
Vue.set(state.groups, groupName, data)
|
||||
},
|
||||
|
||||
'DEL_GROUPS' (state, [groupname]) {
|
||||
DEL_GROUPS(state, [groupname]) {
|
||||
Vue.delete(state.groups, groupname)
|
||||
},
|
||||
|
||||
'SET_PERMISSIONS' (state, [permissions]) {
|
||||
SET_PERMISSIONS(state, [permissions]) {
|
||||
state.permissions = permissions
|
||||
},
|
||||
|
||||
'UPDATE_PERMISSIONS' (state, [_, { groupName, action, permId }]) {
|
||||
UPDATE_PERMISSIONS(state, [_, { groupName, action, permId }]) {
|
||||
// FIXME hacky way to update the store
|
||||
const permissions = state.groups[groupName].permissions
|
||||
if (action === 'add') {
|
||||
|
@ -134,56 +132,103 @@ export default {
|
|||
const index = permissions.indexOf(permId)
|
||||
if (index > -1) permissions.splice(index, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
'GET' (
|
||||
GET(
|
||||
{ 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]
|
||||
// if data has already been queried, simply return
|
||||
const ignoreCache = !rootState.cache || noCache || false
|
||||
if (currentState !== undefined && !ignoreCache) return currentState
|
||||
return api.fetch('GET', param ? `${uri}/${param}` : uri, null, humanKey, options).then(responseData => {
|
||||
// FIXME here's an ugly fix to be able to also cache the main domain when querying domains
|
||||
const data = storeKey === 'domains'
|
||||
? responseData
|
||||
: responseData[storeKey] ? responseData[storeKey] : responseData
|
||||
commit(
|
||||
'SET_' + storeKey.toUpperCase(),
|
||||
[param, data, extraParams].filter(item => !isEmptyValue(item))
|
||||
return api
|
||||
.fetch('GET', param ? `${uri}/${param}` : uri, null, humanKey, options)
|
||||
.then((responseData) => {
|
||||
// FIXME here's an ugly fix to be able to also cache the main domain when querying domains
|
||||
const data =
|
||||
storeKey === 'domains'
|
||||
? responseData
|
||||
: 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 }) {
|
||||
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)) {
|
||||
RESET_CACHE_DATA({ state }, keys = Object.keys(state)) {
|
||||
for (const key of keys) {
|
||||
if (key === 'users_details') {
|
||||
state[key] = {}
|
||||
|
@ -191,36 +236,40 @@ export default {
|
|||
state[key] = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
users: state => {
|
||||
users: (state) => {
|
||||
if (state.users) return Object.values(state.users)
|
||||
return state.users
|
||||
},
|
||||
|
||||
userNames: state => {
|
||||
userNames: (state) => {
|
||||
if (state.users) return Object.keys(state.users)
|
||||
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
|
||||
|
||||
const splittedDomains = Object.fromEntries(state.domains.map(domain => {
|
||||
// Keep the main part of the domain and the extension together
|
||||
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
|
||||
domain = domain.split('.')
|
||||
domain.push(domain.pop() + domain.pop())
|
||||
return [domain, domain.reverse()]
|
||||
}))
|
||||
const splittedDomains = Object.fromEntries(
|
||||
state.domains.map((domain) => {
|
||||
// Keep the main part of the domain and the extension together
|
||||
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
|
||||
domain = domain.split('.')
|
||||
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) => {
|
||||
|
@ -230,30 +279,33 @@ export default {
|
|||
// action when state.domain change)
|
||||
const domains = getters.orderedDomains
|
||||
if (!domains) return
|
||||
const dataset = domains.map(name => ({
|
||||
const dataset = domains.map((name) => ({
|
||||
// data to build a hierarchy
|
||||
name,
|
||||
parent: getParentDomain(name, domains),
|
||||
// utility data that will be used by `RecursiveListGroup` component
|
||||
to: { name: 'domain-info', params: { name } },
|
||||
opened: true
|
||||
opened: true,
|
||||
}))
|
||||
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)
|
||||
},
|
||||
|
||||
mainDomain: state => state.main_domain,
|
||||
mainDomain: (state) => state.main_domain,
|
||||
|
||||
domainsAsChoices: state => {
|
||||
domainsAsChoices: (state) => {
|
||||
const mainDomain = state.main_domain
|
||||
return state.domains.map(domain => {
|
||||
return { value: domain, text: domain === mainDomain ? domain + ' ★' : domain }
|
||||
return state.domains.map((domain) => {
|
||||
return {
|
||||
value: domain,
|
||||
text: domain === mainDomain ? domain + ' ★' : domain,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -14,6 +14,6 @@ export default new Vuex.Store({
|
|||
getters: settings.getters,
|
||||
modules: {
|
||||
info,
|
||||
data
|
||||
}
|
||||
data,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -19,32 +19,32 @@ export default {
|
|||
tempMessages: [], // Array of messages
|
||||
routerKey: undefined, // String if current route has params
|
||||
breadcrumb: [], // Array of routes
|
||||
transitionName: null // String of CSS class if transitions are enabled
|
||||
transitionName: null, // String of CSS class if transitions are enabled
|
||||
},
|
||||
|
||||
mutations: {
|
||||
'SET_INSTALLED' (state, boolean) {
|
||||
SET_INSTALLED(state, boolean) {
|
||||
state.installed = boolean
|
||||
},
|
||||
|
||||
'SET_CONNECTED' (state, boolean) {
|
||||
SET_CONNECTED(state, boolean) {
|
||||
localStorage.setItem('connected', boolean)
|
||||
state.connected = boolean
|
||||
},
|
||||
|
||||
'SET_YUNOHOST_INFOS' (state, yunohost) {
|
||||
SET_YUNOHOST_INFOS(state, yunohost) {
|
||||
state.yunohost = yunohost
|
||||
},
|
||||
|
||||
'SET_WAITING' (state, boolean) {
|
||||
SET_WAITING(state, boolean) {
|
||||
state.waiting = boolean
|
||||
},
|
||||
|
||||
'SET_RECONNECTING' (state, args) {
|
||||
SET_RECONNECTING(state, args) {
|
||||
state.reconnecting = args
|
||||
},
|
||||
|
||||
'ADD_REQUEST' (state, request) {
|
||||
ADD_REQUEST(state, request) {
|
||||
if (state.requests.length > 10) {
|
||||
// We do not remove requests right after it resolves since an error might bring
|
||||
// one back to life but we can safely remove some here.
|
||||
|
@ -53,35 +53,38 @@ export default {
|
|||
state.requests.push(request)
|
||||
},
|
||||
|
||||
'UPDATE_REQUEST' (state, { request, key, value }) {
|
||||
UPDATE_REQUEST(state, { request, key, value }) {
|
||||
// This rely on data persistance and reactivity.
|
||||
Vue.set(request, key, value)
|
||||
},
|
||||
|
||||
'REMOVE_REQUEST' (state, request) {
|
||||
REMOVE_REQUEST(state, request) {
|
||||
const index = state.requests.lastIndexOf(request)
|
||||
state.requests.splice(index, 1)
|
||||
},
|
||||
|
||||
'ADD_HISTORY_ACTION' (state, request) {
|
||||
ADD_HISTORY_ACTION(state, request) {
|
||||
state.history.push(request)
|
||||
},
|
||||
|
||||
'ADD_TEMP_MESSAGE' (state, { request, message, type }) {
|
||||
ADD_TEMP_MESSAGE(state, { request, message, type }) {
|
||||
state.tempMessages.push([message, type])
|
||||
},
|
||||
|
||||
'UPDATE_DISPLAYED_MESSAGES' (state, { request }) {
|
||||
UPDATE_DISPLAYED_MESSAGES(state, { request }) {
|
||||
if (!state.tempMessages.length) {
|
||||
state.historyTimer = null
|
||||
return
|
||||
}
|
||||
|
||||
const { messages, warnings, errors } = state.tempMessages.reduce((acc, [message, type]) => {
|
||||
acc.messages.push(message)
|
||||
if (['error', 'warning'].includes(type)) acc[type + 's']++
|
||||
return acc
|
||||
}, { messages: [], warnings: 0, errors: 0 })
|
||||
const { messages, warnings, errors } = state.tempMessages.reduce(
|
||||
(acc, [message, type]) => {
|
||||
acc.messages.push(message)
|
||||
if (['error', 'warning'].includes(type)) acc[type + 's']++
|
||||
return acc
|
||||
},
|
||||
{ messages: [], warnings: 0, errors: 0 },
|
||||
)
|
||||
state.tempMessages = []
|
||||
state.historyTimer = null
|
||||
request.messages = request.messages.concat(messages)
|
||||
|
@ -89,7 +92,7 @@ export default {
|
|||
request.errors += errors
|
||||
},
|
||||
|
||||
'SET_ERROR' (state, request) {
|
||||
SET_ERROR(state, request) {
|
||||
if (request) {
|
||||
state.error = request
|
||||
} else {
|
||||
|
@ -97,21 +100,21 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
'SET_ROUTER_KEY' (state, key) {
|
||||
SET_ROUTER_KEY(state, key) {
|
||||
state.routerKey = key
|
||||
},
|
||||
|
||||
'SET_BREADCRUMB' (state, breadcrumb) {
|
||||
SET_BREADCRUMB(state, breadcrumb) {
|
||||
state.breadcrumb = breadcrumb
|
||||
},
|
||||
|
||||
'SET_TRANSITION_NAME' (state, transitionName) {
|
||||
SET_TRANSITION_NAME(state, transitionName) {
|
||||
state.transitionName = transitionName
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async 'ON_APP_CREATED' ({ dispatch, state }) {
|
||||
async ON_APP_CREATED({ dispatch, state }) {
|
||||
await dispatch('CHECK_INSTALL')
|
||||
|
||||
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
|
||||
// a timeout of the same delay.
|
||||
// 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
|
||||
// and login prompt will be shown automaticly
|
||||
await dispatch('GET_YUNOHOST_INFOS')
|
||||
|
@ -145,54 +148,77 @@ export default {
|
|||
await dispatch('GET', { uri: 'domains', storeKey: 'domains' })
|
||||
},
|
||||
|
||||
'RESET_CONNECTED' ({ commit }) {
|
||||
RESET_CONNECTED({ commit }) {
|
||||
commit('SET_CONNECTED', false)
|
||||
commit('SET_YUNOHOST_INFOS', null)
|
||||
},
|
||||
|
||||
'DISCONNECT' ({ dispatch }, route = router.currentRoute) {
|
||||
DISCONNECT({ dispatch }, route = router.currentRoute) {
|
||||
dispatch('RESET_CONNECTED')
|
||||
if (router.currentRoute.name === 'login') return
|
||||
router.push({
|
||||
name: 'login',
|
||||
// Add a redirect query if next route is not unknown (like `logout`) or `login`
|
||||
query: route && !['login', null].includes(route.name)
|
||||
? { redirect: route.path }
|
||||
: {}
|
||||
query:
|
||||
route && !['login', null].includes(route.name)
|
||||
? { redirect: route.path }
|
||||
: {},
|
||||
})
|
||||
},
|
||||
|
||||
'LOGIN' ({ dispatch }, credentials) {
|
||||
return api.post('login', { credentials }, null, { websocket: false }).then(() => {
|
||||
dispatch('CONNECT')
|
||||
})
|
||||
LOGIN({ dispatch }, credentials) {
|
||||
return api
|
||||
.post('login', { credentials }, null, { websocket: false })
|
||||
.then(() => {
|
||||
dispatch('CONNECT')
|
||||
})
|
||||
},
|
||||
|
||||
'LOGOUT' ({ dispatch }) {
|
||||
LOGOUT({ dispatch }) {
|
||||
dispatch('DISCONNECT')
|
||||
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)
|
||||
commit('SET_RECONNECTING', args)
|
||||
dispatch('RESET_CONNECTED')
|
||||
},
|
||||
|
||||
'GET_YUNOHOST_INFOS' ({ commit }) {
|
||||
return api.get('versions').then(versions => {
|
||||
GET_YUNOHOST_INFOS({ commit }) {
|
||||
return api.get('versions').then((versions) => {
|
||||
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
|
||||
const { key, ...args } = isObjectLiteral(humanKey) ? humanKey : { key: humanKey }
|
||||
const humanRoute = key ? i18n.t('human_routes.' + key, args) : `[${method}] /${uri}`
|
||||
const { key, ...args } = isObjectLiteral(humanKey)
|
||||
? 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) {
|
||||
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_REQUEST', request)
|
||||
|
@ -208,7 +234,7 @@ export default {
|
|||
return request
|
||||
},
|
||||
|
||||
'END_REQUEST' ({ state, commit }, { request, success, wait }) {
|
||||
END_REQUEST({ state, commit }, { request, success, wait }) {
|
||||
// Update last messages before finishing this request
|
||||
clearTimeout(state.historyTimer)
|
||||
commit('UPDATE_DISPLAYED_MESSAGES', { request })
|
||||
|
@ -216,7 +242,10 @@ export default {
|
|||
let status = success ? 'success' : 'error'
|
||||
if (success && (request.warnings || request.errors)) {
|
||||
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
|
||||
}
|
||||
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) {
|
||||
const message = {
|
||||
text: messages[type].replaceAll('\n', '<br>'),
|
||||
color: type === 'error' ? 'danger' : type
|
||||
color: type === 'error' ? 'danger' : type,
|
||||
}
|
||||
let progressBar = message.text.match(/^\[#*\+*\.*\] > /)
|
||||
if (progressBar) {
|
||||
|
@ -245,7 +274,11 @@ export default {
|
|||
for (const char of progressBar) {
|
||||
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) {
|
||||
// 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) {
|
||||
// Unauthorized
|
||||
dispatch('DISCONNECT')
|
||||
|
@ -277,12 +310,12 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
'REVIEW_ERROR' ({ commit }, request) {
|
||||
REVIEW_ERROR({ commit }, request) {
|
||||
request.review = true
|
||||
commit('SET_ERROR', request)
|
||||
},
|
||||
|
||||
'DISMISS_ERROR' ({ commit, state }, { initial, review = false }) {
|
||||
DISMISS_ERROR({ commit, state }, { initial, review = false }) {
|
||||
if (initial && !review) {
|
||||
// In case of an initial request (data that is needed by a view to render itself),
|
||||
// try to go back so the user doesn't get stuck at a never ending skeleton view.
|
||||
|
@ -296,12 +329,12 @@ export default {
|
|||
commit('SET_ERROR', null)
|
||||
},
|
||||
|
||||
'DISMISS_WARNING' ({ commit, state }, request) {
|
||||
DISMISS_WARNING({ commit, state }, request) {
|
||||
commit('SET_WAITING', false)
|
||||
Vue.delete(request, 'showWarningMessage')
|
||||
},
|
||||
|
||||
'UPDATE_ROUTER_KEY' ({ commit }, { to, from }) {
|
||||
UPDATE_ROUTER_KEY({ commit }, { to, from }) {
|
||||
if (isEmptyValue(to.params)) {
|
||||
commit('SET_ROUTER_KEY', undefined)
|
||||
return
|
||||
|
@ -313,21 +346,24 @@ export default {
|
|||
// Params can be declared in route `meta` to stricly define which params should be
|
||||
// taken into account.
|
||||
const params = to.meta.routerParams
|
||||
? to.meta.routerParams.map(key => to.params[key])
|
||||
? to.meta.routerParams.map((key) => to.params[key])
|
||||
: Object.values(to.params)
|
||||
|
||||
commit('SET_ROUTER_KEY', `${to.name}-${params.join('-')}`)
|
||||
},
|
||||
|
||||
'UPDATE_BREADCRUMB' ({ commit }, { to, from }) {
|
||||
function getRouteNames (route) {
|
||||
UPDATE_BREADCRUMB({ commit }, { to, from }) {
|
||||
function getRouteNames(route) {
|
||||
if (route.meta.breadcrumb) return route.meta.breadcrumb
|
||||
const parentRoute = route.matched.slice().reverse().find(route => route.meta.breadcrumb)
|
||||
const parentRoute = route.matched
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((route) => route.meta.breadcrumb)
|
||||
if (parentRoute) return parentRoute.meta.breadcrumb
|
||||
return []
|
||||
}
|
||||
|
||||
function formatRoute (route) {
|
||||
function formatRoute(route) {
|
||||
const { trad, param } = route.meta.args || {}
|
||||
let text = ''
|
||||
// if a traduction key string has been given and we also need to pass
|
||||
|
@ -344,49 +380,55 @@ export default {
|
|||
|
||||
const routeNames = getRouteNames(to)
|
||||
const allRoutes = router.getRoutes()
|
||||
const breadcrumb = routeNames.map(name => {
|
||||
const route = allRoutes.find(route => route.name === name)
|
||||
const breadcrumb = routeNames.map((name) => {
|
||||
const route = allRoutes.find((route) => route.name === name)
|
||||
return formatRoute(route)
|
||||
})
|
||||
|
||||
commit('SET_BREADCRUMB', breadcrumb)
|
||||
|
||||
function getTitle (breadcrumb) {
|
||||
function getTitle(breadcrumb) {
|
||||
if (breadcrumb.length === 0) return formatRoute(to).text
|
||||
return (breadcrumb.length > 2 ? breadcrumb.slice(-2) : breadcrumb).map(route => route.text).reverse().join(' / ')
|
||||
return (breadcrumb.length > 2 ? breadcrumb.slice(-2) : breadcrumb)
|
||||
.map((route) => route.text)
|
||||
.reverse()
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
// Display a simplified breadcrumb as the document title.
|
||||
document.title = `${getTitle(breadcrumb)} | ${i18n.t('yunohost_admin')}`
|
||||
},
|
||||
|
||||
'UPDATE_TRANSITION_NAME' ({ state, commit }, { to, from }) {
|
||||
UPDATE_TRANSITION_NAME({ state, commit }, { to, from }) {
|
||||
// Use the breadcrumb array length as a direction indicator
|
||||
const toDepth = (to.meta.breadcrumb || []).length
|
||||
const fromDepth = (from.meta.breadcrumb || []).length
|
||||
commit('SET_TRANSITION_NAME', toDepth < fromDepth ? 'slide-right' : 'slide-left')
|
||||
}
|
||||
commit(
|
||||
'SET_TRANSITION_NAME',
|
||||
toDepth < fromDepth ? 'slide-right' : 'slide-left',
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
host: state => state.host,
|
||||
installed: state => state.installed,
|
||||
connected: state => state.connected,
|
||||
yunohost: state => state.yunohost,
|
||||
error: state => state.error,
|
||||
waiting: state => state.waiting,
|
||||
reconnecting: state => state.reconnecting,
|
||||
history: state => state.history,
|
||||
lastAction: state => state.history[state.history.length - 1],
|
||||
currentRequest: state => {
|
||||
host: (state) => state.host,
|
||||
installed: (state) => state.installed,
|
||||
connected: (state) => state.connected,
|
||||
yunohost: (state) => state.yunohost,
|
||||
error: (state) => state.error,
|
||||
waiting: (state) => state.waiting,
|
||||
reconnecting: (state) => state.reconnecting,
|
||||
history: (state) => state.history,
|
||||
lastAction: (state) => state.history[state.history.length - 1],
|
||||
currentRequest: (state) => {
|
||||
const request = state.requests.find(({ status }) => status === 'pending')
|
||||
return request || state.requests[state.requests.length - 1]
|
||||
},
|
||||
routerKey: state => state.routerKey,
|
||||
breadcrumb: state => state.breadcrumb,
|
||||
transitionName: state => state.transitionName,
|
||||
routerKey: (state) => state.routerKey,
|
||||
breadcrumb: (state) => state.breadcrumb,
|
||||
transitionName: (state) => state.transitionName,
|
||||
ssoLink: (state, getters) => {
|
||||
return `//${getters.mainDomain ?? state.host}/yunohost/sso`
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
*/
|
||||
|
||||
import i18n from '@/i18n'
|
||||
import { loadLocaleMessages, updateDocumentLocale, loadDateFnsLocale } from '@/i18n/helpers'
|
||||
import {
|
||||
loadLocaleMessages,
|
||||
updateDocumentLocale,
|
||||
loadDateFnsLocale,
|
||||
} from '@/i18n/helpers'
|
||||
import supportedLocales from '@/i18n/supportedLocales'
|
||||
|
||||
export default {
|
||||
|
@ -16,48 +20,48 @@ export default {
|
|||
theme: localStorage.getItem('theme') === 'true',
|
||||
experimental: localStorage.getItem('experimental') === 'true',
|
||||
spinner: 'pacman',
|
||||
supportedLocales
|
||||
supportedLocales,
|
||||
},
|
||||
|
||||
mutations: {
|
||||
'SET_LOCALE' (state, locale) {
|
||||
SET_LOCALE(state, locale) {
|
||||
localStorage.setItem('locale', locale)
|
||||
state.locale = locale
|
||||
},
|
||||
|
||||
'SET_FALLBACKLOCALE' (state, locale) {
|
||||
SET_FALLBACKLOCALE(state, locale) {
|
||||
localStorage.setItem('fallbackLocale', locale)
|
||||
state.fallbackLocale = locale
|
||||
},
|
||||
|
||||
'SET_CACHE' (state, boolean) {
|
||||
SET_CACHE(state, boolean) {
|
||||
localStorage.setItem('cache', boolean)
|
||||
state.cache = boolean
|
||||
},
|
||||
|
||||
'SET_TRANSITIONS' (state, boolean) {
|
||||
SET_TRANSITIONS(state, boolean) {
|
||||
localStorage.setItem('transitions', boolean)
|
||||
state.transitions = boolean
|
||||
},
|
||||
|
||||
'SET_EXPERIMENTAL' (state, boolean) {
|
||||
SET_EXPERIMENTAL(state, boolean) {
|
||||
localStorage.setItem('experimental', boolean)
|
||||
state.experimental = boolean
|
||||
},
|
||||
|
||||
'SET_SPINNER' (state, spinner) {
|
||||
SET_SPINNER(state, spinner) {
|
||||
state.spinner = spinner
|
||||
},
|
||||
|
||||
'SET_THEME' (state, boolean) {
|
||||
SET_THEME(state, boolean) {
|
||||
localStorage.setItem('theme', boolean)
|
||||
state.theme = boolean
|
||||
document.documentElement.setAttribute('dark-theme', boolean)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
'UPDATE_LOCALE' ({ commit }, locale) {
|
||||
UPDATE_LOCALE({ commit }, locale) {
|
||||
loadLocaleMessages(locale).then(() => {
|
||||
updateDocumentLocale(locale)
|
||||
commit('SET_LOCALE', locale)
|
||||
|
@ -67,31 +71,33 @@ export default {
|
|||
loadDateFnsLocale(locale)
|
||||
},
|
||||
|
||||
'UPDATE_FALLBACKLOCALE' ({ commit }, locale) {
|
||||
UPDATE_FALLBACKLOCALE({ commit }, locale) {
|
||||
loadLocaleMessages(locale).then(() => {
|
||||
commit('SET_FALLBACKLOCALE', locale)
|
||||
i18n.fallbackLocale = [locale, 'en']
|
||||
})
|
||||
},
|
||||
|
||||
'UPDATE_THEME' ({ commit }, theme) {
|
||||
UPDATE_THEME({ commit }, theme) {
|
||||
commit('SET_THEME', theme)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
locale: state => (state.locale),
|
||||
fallbackLocale: state => (state.fallbackLocale),
|
||||
cache: state => (state.cache),
|
||||
transitions: state => (state.transitions),
|
||||
theme: state => (state.theme),
|
||||
experimental: state => state.experimental,
|
||||
spinner: state => state.spinner,
|
||||
locale: (state) => state.locale,
|
||||
fallbackLocale: (state) => state.fallbackLocale,
|
||||
cache: (state) => state.cache,
|
||||
transitions: (state) => state.transitions,
|
||||
theme: (state) => state.theme,
|
||||
experimental: (state) => state.experimental,
|
||||
spinner: (state) => state.spinner,
|
||||
|
||||
availableLocales: state => {
|
||||
return Object.entries(state.supportedLocales).map(([locale, { name }]) => {
|
||||
return { value: locale, text: name }
|
||||
})
|
||||
}
|
||||
}
|
||||
availableLocales: (state) => {
|
||||
return Object.entries(state.supportedLocales).map(
|
||||
([locale, { name }]) => {
|
||||
return { value: locale, text: name }
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
export default {
|
||||
name: 'HomeView',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
menu: [
|
||||
{ routeName: 'user-list', icon: 'users', translation: 'users' },
|
||||
|
@ -26,11 +26,15 @@ export default {
|
|||
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' },
|
||||
{ routeName: 'update', icon: 'refresh', translation: 'system_update' },
|
||||
{ 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>
|
||||
|
||||
|
|
|
@ -1,19 +1,31 @@
|
|||
<template>
|
||||
<CardForm
|
||||
:title="$t('login')" icon="lock"
|
||||
:validation="$v" :server-error="serverError"
|
||||
:title="$t('login')"
|
||||
icon="lock"
|
||||
:validation="$v"
|
||||
:server-error="serverError"
|
||||
@submit.prevent="login"
|
||||
>
|
||||
<!-- 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 -->
|
||||
<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>
|
||||
<BButton
|
||||
type="submit" variant="success"
|
||||
:disabled="!installed" form="ynh-form"
|
||||
type="submit"
|
||||
variant="success"
|
||||
:disabled="!installed"
|
||||
form="ynh-form"
|
||||
>
|
||||
{{ $t('login') }}
|
||||
</BButton>
|
||||
|
@ -32,63 +44,68 @@ export default {
|
|||
mixins: [validationMixin],
|
||||
|
||||
props: {
|
||||
forceReload: { type: Boolean, default: false }
|
||||
forceReload: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
serverError: '',
|
||||
form: {
|
||||
username: '',
|
||||
password: ''
|
||||
password: '',
|
||||
},
|
||||
fields: {
|
||||
username: {
|
||||
label: this.$i18n.t('user_username'),
|
||||
props: {
|
||||
id: 'username',
|
||||
autocomplete: 'username'
|
||||
}
|
||||
autocomplete: 'username',
|
||||
},
|
||||
},
|
||||
password: {
|
||||
label: this.$i18n.t('password'),
|
||||
props: {
|
||||
id: 'password',
|
||||
type: 'password',
|
||||
autocomplete: 'current-password'
|
||||
}
|
||||
}
|
||||
}
|
||||
autocomplete: 'current-password',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['installed'])
|
||||
...mapGetters(['installed']),
|
||||
},
|
||||
|
||||
validations () {
|
||||
validations() {
|
||||
return {
|
||||
form: {
|
||||
username: { required, alphalownumdot_ },
|
||||
password: { required, passwordLenght: minLength(4) }
|
||||
}
|
||||
password: { required, passwordLenght: minLength(4) },
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
login () {
|
||||
login() {
|
||||
const credentials = [this.form.username, this.form.password].join(':')
|
||||
this.$store.dispatch('LOGIN', credentials).then(() => {
|
||||
if (this.forceReload) {
|
||||
window.location.href = '/yunohost/admin/'
|
||||
} else {
|
||||
this.$router.push(this.$router.currentRoute.query.redirect || { name: 'home' })
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIUnauthorizedError') throw err
|
||||
this.serverError = this.$i18n.t('wrong_password_or_username')
|
||||
})
|
||||
}
|
||||
}
|
||||
this.$store
|
||||
.dispatch('LOGIN', credentials)
|
||||
.then(() => {
|
||||
if (this.forceReload) {
|
||||
window.location.href = '/yunohost/admin/'
|
||||
} else {
|
||||
this.$router.push(
|
||||
this.$router.currentRoute.query.redirect || { name: 'home' },
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'APIUnauthorizedError') throw err
|
||||
this.serverError = this.$i18n.t('wrong_password_or_username')
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<p class="alert alert-info">
|
||||
<span v-t="'postinstall_intro_2'" />
|
||||
<br>
|
||||
<br />
|
||||
<span v-html="$t('postinstall_intro_3')" />
|
||||
</p>
|
||||
|
||||
|
@ -20,7 +20,9 @@
|
|||
<!-- DOMAIN SETUP STEP -->
|
||||
<template v-else-if="step === 'domain'">
|
||||
<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"
|
||||
>
|
||||
<template #disclaimer>
|
||||
|
@ -36,9 +38,12 @@
|
|||
<!-- FIRST USER SETUP STEP -->
|
||||
<template v-else-if="step === 'user'">
|
||||
<CardForm
|
||||
:title="$t('postinstall.user.title')" icon="user-plus"
|
||||
:validation="$v" :server-error="serverError"
|
||||
:submit-text="$t('next')" @submit.prevent="setUser"
|
||||
:title="$t('postinstall.user.title')"
|
||||
icon="user-plus"
|
||||
:validation="$v"
|
||||
:server-error="serverError"
|
||||
:submit-text="$t('next')"
|
||||
@submit.prevent="setUser"
|
||||
>
|
||||
<ReadOnlyAlertItem
|
||||
:label="$t('postinstall.user.first_user_help')"
|
||||
|
@ -46,8 +51,11 @@
|
|||
/>
|
||||
|
||||
<FormField
|
||||
v-for="(field, name) in fields" :key="name"
|
||||
v-bind="field" v-model="user[name]" :validation="$v.user[name]"
|
||||
v-for="(field, name) in fields"
|
||||
:key="name"
|
||||
v-bind="field"
|
||||
v-model="user[name]"
|
||||
:validation="$v.user[name]"
|
||||
/>
|
||||
</CardForm>
|
||||
|
||||
|
@ -87,7 +95,13 @@ import api from '@/api'
|
|||
import { DomainForm } from '@/views/_partials'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
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 {
|
||||
name: 'PostInstall',
|
||||
|
@ -96,10 +110,10 @@ export default {
|
|||
|
||||
components: {
|
||||
DomainForm,
|
||||
LoginView
|
||||
LoginView,
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
step: 'start',
|
||||
serverError: '',
|
||||
|
@ -109,98 +123,110 @@ export default {
|
|||
username: '',
|
||||
fullname: '',
|
||||
password: '',
|
||||
confirmation: ''
|
||||
confirmation: '',
|
||||
},
|
||||
|
||||
fields: {
|
||||
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: {
|
||||
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: {
|
||||
label: this.$i18n.t('password'),
|
||||
description: this.$i18n.t('good_practices_about_admin_password'),
|
||||
descriptionVariant: 'warning',
|
||||
props: { id: 'password', placeholder: '••••••••', type: 'password' }
|
||||
props: { id: 'password', placeholder: '••••••••', type: 'password' },
|
||||
},
|
||||
|
||||
confirmation: {
|
||||
label: this.$i18n.t('password_confirmation'),
|
||||
props: { id: 'confirmation', placeholder: '••••••••', type: 'password' }
|
||||
}
|
||||
}
|
||||
props: {
|
||||
id: 'confirmation',
|
||||
placeholder: '••••••••',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
goToStep (step) {
|
||||
goToStep(step) {
|
||||
this.serverError = ''
|
||||
this.step = step
|
||||
},
|
||||
|
||||
setDomain ({ domain, dyndns_recovery_password }) {
|
||||
setDomain({ domain, dyndns_recovery_password }) {
|
||||
this.domain = domain
|
||||
this.dyndns_recovery_password = dyndns_recovery_password
|
||||
this.goToStep('user')
|
||||
},
|
||||
|
||||
async setUser () {
|
||||
async setUser() {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_postinstall', { domain: this.domain })
|
||||
this.$i18n.t('confirm_postinstall', { domain: this.domain }),
|
||||
)
|
||||
if (!confirmed) return
|
||||
this.performPostInstall()
|
||||
},
|
||||
|
||||
async performPostInstall (force = false) {
|
||||
async performPostInstall(force = false) {
|
||||
const data = await formatFormData({
|
||||
domain: this.domain,
|
||||
dyndns_recovery_password: this.dyndns_recovery_password,
|
||||
username: this.user.username,
|
||||
fullname: this.user.fullname,
|
||||
password: this.user.password
|
||||
password: this.user.password,
|
||||
})
|
||||
|
||||
// FIXME does the api will throw an error for bad passwords ?
|
||||
api.post(
|
||||
'postinstall' + (force ? '?force_diskspace' : ''),
|
||||
data,
|
||||
{ key: 'postinstall' }
|
||||
).then(() => {
|
||||
// Display success message and allow the user to login
|
||||
this.goToStep('login')
|
||||
}).catch(err => {
|
||||
const hasWordsInError = (words) => words.some((word) => (err.key || err.message).includes(word))
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
if (err.key === 'postinstall_low_rootfsspace') {
|
||||
this.step = 'rootfsspace-error'
|
||||
} else if (hasWordsInError(['domain', 'dyndns'])) {
|
||||
this.step = 'domain'
|
||||
} else if (hasWordsInError(['password', 'user'])) {
|
||||
this.step = 'user'
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
this.serverError = err.message
|
||||
})
|
||||
}
|
||||
api
|
||||
.post('postinstall' + (force ? '?force_diskspace' : ''), data, {
|
||||
key: 'postinstall',
|
||||
})
|
||||
.then(() => {
|
||||
// Display success message and allow the user to login
|
||||
this.goToStep('login')
|
||||
})
|
||||
.catch((err) => {
|
||||
const hasWordsInError = (words) =>
|
||||
words.some((word) => (err.key || err.message).includes(word))
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
if (err.key === 'postinstall_low_rootfsspace') {
|
||||
this.step = 'rootfsspace-error'
|
||||
} else if (hasWordsInError(['domain', 'dyndns'])) {
|
||||
this.step = 'domain'
|
||||
} else if (hasWordsInError(['password', 'user'])) {
|
||||
this.step = 'user'
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
this.serverError = err.message
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
validations () {
|
||||
validations() {
|
||||
return {
|
||||
user: {
|
||||
username: { required, alphalownumdot_ },
|
||||
fullname: { required, name },
|
||||
password: { required, passwordLenght: minLength(8) },
|
||||
confirmation: { required, passwordMatch: sameAs('password') }
|
||||
}
|
||||
confirmation: { required, passwordMatch: sameAs('password') },
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
<template>
|
||||
<CardForm
|
||||
:title="title" icon="globe" :submit-text="submitText"
|
||||
:validation="$v" :server-error="serverError"
|
||||
:title="title"
|
||||
icon="globe"
|
||||
:submit-text="submitText"
|
||||
:validation="$v"
|
||||
:server-error="serverError"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<template #disclaimer>
|
||||
|
@ -9,7 +12,9 @@
|
|||
</template>
|
||||
|
||||
<BFormRadio
|
||||
v-model="selected" name="domain-type" value="domain"
|
||||
v-model="selected"
|
||||
name="domain-type"
|
||||
value="domain"
|
||||
:class="domainIsVisible ? null : 'collapsed'"
|
||||
:aria-expanded="domainIsVisible ? 'true' : 'false'"
|
||||
aria-controls="collapse-domain"
|
||||
|
@ -25,13 +30,17 @@
|
|||
</p>
|
||||
|
||||
<FormField
|
||||
v-bind="fields.domain" v-model="form.domain"
|
||||
:validation="$v.form.domain" class="mt-3"
|
||||
v-bind="fields.domain"
|
||||
v-model="form.domain"
|
||||
:validation="$v.form.domain"
|
||||
class="mt-3"
|
||||
/>
|
||||
</BCollapse>
|
||||
|
||||
<BFormRadio
|
||||
v-model="selected" name="domain-type" value="dynDomain"
|
||||
v-model="selected"
|
||||
name="domain-type"
|
||||
value="dynDomain"
|
||||
:disabled="dynDnsForbiden"
|
||||
:class="dynDomainIsVisible ? null : 'collapsed'"
|
||||
:aria-expanded="dynDomainIsVisible ? 'true' : 'false'"
|
||||
|
@ -47,7 +56,11 @@
|
|||
<span class="pl-1" v-html="$t('domain.add.from_yunohost_desc')" />
|
||||
</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 }">
|
||||
<AdressInputSelect v-bind="self" v-model="form.dynDomain" />
|
||||
</template>
|
||||
|
@ -65,10 +78,16 @@
|
|||
v-model="form.dynDomainPasswordConfirmation"
|
||||
/>
|
||||
</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
|
||||
v-model="selected" name="domain-type" value="localDomain"
|
||||
v-model="selected"
|
||||
name="domain-type"
|
||||
value="localDomain"
|
||||
:class="localDomainIsVisible ? null : 'collapsed'"
|
||||
:aria-expanded="localDomainIsVisible ? 'true' : 'false'"
|
||||
aria-controls="collapse-localDomain"
|
||||
|
@ -82,7 +101,11 @@
|
|||
<span class="pl-1" v-html="$t('domain.add.from_local_desc')" />
|
||||
</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 }">
|
||||
<AdressInputSelect v-bind="self" v-model="form.localDomain" />
|
||||
</template>
|
||||
|
@ -97,7 +120,13 @@ import { validationMixin } from 'vuelidate'
|
|||
|
||||
import AdressInputSelect from '@/components/AdressInputSelect.vue'
|
||||
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 {
|
||||
name: 'DomainForm',
|
||||
|
@ -105,10 +134,10 @@ export default {
|
|||
props: {
|
||||
title: { type: String, required: true },
|
||||
submitText: { type: String, default: null },
|
||||
serverError: { type: String, default: '' }
|
||||
serverError: { type: String, default: '' },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
selected: '',
|
||||
|
||||
|
@ -117,7 +146,7 @@ export default {
|
|||
dynDomain: { localPart: '', separator: '.', domain: 'nohost.me' },
|
||||
dynDomainPassword: '',
|
||||
dynDomainPasswordConfirmation: '',
|
||||
localDomain: { localPart: '', separator: '.', domain: 'local' }
|
||||
localDomain: { localPart: '', separator: '.', domain: 'local' },
|
||||
},
|
||||
|
||||
fields: {
|
||||
|
@ -125,8 +154,8 @@ export default {
|
|||
label: this.$i18n.t('domain_name'),
|
||||
props: {
|
||||
id: 'domain',
|
||||
placeholder: this.$i18n.t('placeholder.domain')
|
||||
}
|
||||
placeholder: this.$i18n.t('placeholder.domain'),
|
||||
},
|
||||
},
|
||||
|
||||
dynDomain: {
|
||||
|
@ -135,8 +164,8 @@ export default {
|
|||
id: 'dyn-domain',
|
||||
placeholder: this.$i18n.t('placeholder.domain').split('.')[0],
|
||||
type: 'domain',
|
||||
choices: ['nohost.me', 'noho.st', 'ynh.fr']
|
||||
}
|
||||
choices: ['nohost.me', 'noho.st', 'ynh.fr'],
|
||||
},
|
||||
},
|
||||
|
||||
dynDomainPassword: {
|
||||
|
@ -145,8 +174,8 @@ export default {
|
|||
props: {
|
||||
id: 'dyn-dns-password',
|
||||
placeholder: '••••••••',
|
||||
type: 'password'
|
||||
}
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
|
||||
dynDomainPasswordConfirmation: {
|
||||
|
@ -154,8 +183,8 @@ export default {
|
|||
props: {
|
||||
id: 'dyn-dns-password-confirmation',
|
||||
placeholder: '••••••••',
|
||||
type: 'password'
|
||||
}
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
|
||||
localDomain: {
|
||||
|
@ -164,68 +193,70 @@ export default {
|
|||
id: 'dyn-domain',
|
||||
placeholder: this.$i18n.t('placeholder.domain').split('.')[0],
|
||||
type: 'domain',
|
||||
choices: ['local', 'test']
|
||||
}
|
||||
}
|
||||
}
|
||||
choices: ['local', 'test'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['domains']),
|
||||
|
||||
dynDnsForbiden () {
|
||||
dynDnsForbiden() {
|
||||
if (!this.domains) return false
|
||||
const dynDomains = this.fields.dynDomain.props.choices
|
||||
return this.domains.some(domain => {
|
||||
return dynDomains.some(dynDomain => domain.includes(dynDomain))
|
||||
return this.domains.some((domain) => {
|
||||
return dynDomains.some((dynDomain) => domain.includes(dynDomain))
|
||||
})
|
||||
},
|
||||
|
||||
domainIsVisible () {
|
||||
domainIsVisible() {
|
||||
return this.selected === 'domain'
|
||||
},
|
||||
|
||||
dynDomainIsVisible () {
|
||||
dynDomainIsVisible() {
|
||||
return this.selected === 'dynDomain'
|
||||
},
|
||||
|
||||
localDomainIsVisible () {
|
||||
localDomainIsVisible() {
|
||||
return this.selected === 'localDomain'
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
validations () {
|
||||
validations() {
|
||||
return {
|
||||
selected: { required },
|
||||
form: ['domain', 'localDomain'].includes(this.selected)
|
||||
? {
|
||||
[this.selected]: this.selected === 'domain'
|
||||
? { required, domain }
|
||||
: { localPart: { required, dynDomain } }
|
||||
}
|
||||
[this.selected]:
|
||||
this.selected === 'domain'
|
||||
? { required, domain }
|
||||
: { localPart: { required, dynDomain } },
|
||||
}
|
||||
: {
|
||||
dynDomain: { localPart: { required, dynDomain } },
|
||||
dynDomainPassword: { passwordLenght: minLength(8) },
|
||||
dynDomainPasswordConfirmation: { passwordMatch: sameAs('dynDomainPassword') }
|
||||
}
|
||||
dynDomain: { localPart: { required, dynDomain } },
|
||||
dynDomainPassword: { passwordLenght: minLength(8) },
|
||||
dynDomainPasswordConfirmation: {
|
||||
passwordMatch: sameAs('dynDomainPassword'),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async onSubmit () {
|
||||
async onSubmit() {
|
||||
const domainType = this.selected
|
||||
const form = await formatFormData({
|
||||
domain: this.form[domainType],
|
||||
dyndns_recovery_password: domainType === 'dynDomain'
|
||||
? this.form.dynDomainPassword
|
||||
: ''
|
||||
dyndns_recovery_password:
|
||||
domainType === 'dynDomain' ? this.form.dynDomainPassword : '',
|
||||
})
|
||||
this.$emit('submit', form)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
created() {
|
||||
if (this.dynDnsForbiden) {
|
||||
this.selected = 'domain'
|
||||
}
|
||||
|
@ -234,7 +265,7 @@ export default {
|
|||
mixins: [validationMixin],
|
||||
|
||||
components: {
|
||||
AdressInputSelect
|
||||
}
|
||||
AdressInputSelect,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -8,15 +8,17 @@
|
|||
|
||||
<div class="alert alert-info my-3">
|
||||
<span v-html="$t('api_error.help')" />
|
||||
<br>{{ $t('api_error.info') }}
|
||||
<br />{{ $t('api_error.info') }}
|
||||
</div>
|
||||
|
||||
<!-- FIXME USE DD DL DT -->
|
||||
<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>
|
||||
<strong v-t="'action'" />: <code>"{{ error.method }}" {{ error.path }}</code>
|
||||
<strong v-t="'action'" />:
|
||||
<code>"{{ error.method }}" {{ error.path }}</code>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
@ -43,10 +45,7 @@
|
|||
|
||||
<BCardFooter footer-bg-variant="danger">
|
||||
<!-- TODO add copy error ? -->
|
||||
<BButton
|
||||
variant="light" size="sm"
|
||||
v-t="'ok'" @click="dismiss"
|
||||
/>
|
||||
<BButton variant="light" size="sm" v-t="'ok'" @click="dismiss" />
|
||||
</BCardFooter>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -58,35 +57,36 @@ export default {
|
|||
name: 'ErrorDisplay',
|
||||
|
||||
components: {
|
||||
MessageListGroup
|
||||
MessageListGroup,
|
||||
},
|
||||
|
||||
props: {
|
||||
request: { type: [Object, null], default: null }
|
||||
request: { type: [Object, null], default: null },
|
||||
},
|
||||
|
||||
computed: {
|
||||
error () {
|
||||
error() {
|
||||
return this.request.error
|
||||
},
|
||||
|
||||
messages () {
|
||||
messages() {
|
||||
const messages = this.request.messages
|
||||
if (messages && messages.length > 0) return messages
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
dismiss () {
|
||||
dismiss() {
|
||||
this.$store.dispatch('DISMISS_ERROR', this.request)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
code, pre code {
|
||||
code,
|
||||
pre code {
|
||||
color: $black;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,22 +2,29 @@
|
|||
<BCard no-body id="console">
|
||||
<!-- HISTORY BAR -->
|
||||
<BCardHeader
|
||||
role="button" tabindex="0"
|
||||
:aria-expanded="open ? 'true' : 'false'" aria-controls="console-collapse"
|
||||
header-tag="header" :header-bg-variant="open ? 'best' : 'white'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="open ? 'true' : 'false'"
|
||||
aria-controls="console-collapse"
|
||||
header-tag="header"
|
||||
:header-bg-variant="open ? 'best' : 'white'"
|
||||
:class="{ 'text-white': open }"
|
||||
class="d-flex align-items-center"
|
||||
@mousedown.left.prevent="onHistoryBarClick"
|
||||
@keyup.space.enter.prevent="onHistoryBarKey"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- CURRENT/LAST ACTION -->
|
||||
<BButton
|
||||
v-if="lastAction"
|
||||
size="sm" pill
|
||||
size="sm"
|
||||
pill
|
||||
class="ml-auto py-0"
|
||||
:variant="open ? 'light' : 'best'"
|
||||
@click.prevent="onLastActionClick"
|
||||
|
@ -25,36 +32,49 @@
|
|||
>
|
||||
<small>{{ $t('history.last_action') }}</small>
|
||||
</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>
|
||||
|
||||
<BCollapse id="console-collapse" v-model="open">
|
||||
<div
|
||||
class="accordion" role="tablist"
|
||||
id="history" ref="history"
|
||||
>
|
||||
<div class="accordion" role="tablist" id="history" ref="history">
|
||||
<p v-if="history.length === 0" class="alert m-0 px-2 py-1">
|
||||
{{ $t('history.is_empty') }}
|
||||
</p>
|
||||
|
||||
<!-- ACTION LIST -->
|
||||
<BCard
|
||||
v-for="(action, i) in history" :key="i"
|
||||
no-body class="rounded-0 rounded-top border-left-0 border-right-0"
|
||||
v-for="(action, i) in history"
|
||||
:key="i"
|
||||
no-body
|
||||
class="rounded-0 rounded-top border-left-0 border-right-0"
|
||||
>
|
||||
<!-- 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 -->
|
||||
<QueryHeader
|
||||
role="tab" v-b-toggle="action.messages.length ? 'messages-collapse-' + i : false"
|
||||
:request="action" show-time show-error
|
||||
role="tab"
|
||||
v-b-toggle="
|
||||
action.messages.length ? 'messages-collapse-' + i : false
|
||||
"
|
||||
:request="action"
|
||||
show-time
|
||||
show-error
|
||||
/>
|
||||
</BCardHeader>
|
||||
|
||||
<!-- ACTION MESSAGES -->
|
||||
<BCollapse
|
||||
v-if="action.messages.length"
|
||||
:id="'messages-collapse-' + i" accordion="my-accordion"
|
||||
:id="'messages-collapse-' + i"
|
||||
accordion="my-accordion"
|
||||
role="tabpanel"
|
||||
@shown="scrollToAction(i)"
|
||||
@hide="scrollToAction(i)"
|
||||
|
@ -78,33 +98,35 @@ export default {
|
|||
|
||||
components: {
|
||||
QueryHeader,
|
||||
MessageListGroup
|
||||
MessageListGroup,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: { type: Boolean, default: false },
|
||||
height: { type: [Number, String], default: 30 }
|
||||
height: { type: [Number, String], default: 30 },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
open: false
|
||||
open: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['history', 'lastAction', 'waiting', 'error'])
|
||||
...mapGetters(['history', 'lastAction', 'waiting', 'error']),
|
||||
},
|
||||
|
||||
methods: {
|
||||
scrollToAction (actionIndex) {
|
||||
const actionCard = this.$el.querySelector('#messages-collapse-' + actionIndex).parentElement
|
||||
scrollToAction(actionIndex) {
|
||||
const actionCard = this.$el.querySelector(
|
||||
'#messages-collapse-' + actionIndex,
|
||||
).parentElement
|
||||
const headerOffset = actionCard.firstElementChild.offsetHeight
|
||||
// Can't use `scrollIntoView()` here since it will also scroll in the main content.
|
||||
this.$refs.history.scrollTop = actionCard.offsetTop - headerOffset
|
||||
},
|
||||
|
||||
async onLastActionClick () {
|
||||
async onLastActionClick() {
|
||||
if (!this.open) {
|
||||
this.open = true
|
||||
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.
|
||||
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
|
||||
},
|
||||
|
||||
onHistoryBarClick (e) {
|
||||
onHistoryBarClick(e) {
|
||||
// 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
|
||||
let mousePos = e.clientY
|
||||
|
@ -180,8 +210,8 @@ export default {
|
|||
}
|
||||
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -207,7 +237,6 @@ export default {
|
|||
border-bottom-left-radius: 0;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
|
||||
& > header {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
|
|
@ -20,7 +20,9 @@
|
|||
|
||||
<div class="d-flex justify-content-end">
|
||||
<BButton
|
||||
variant="success" v-t="'retry'" class="ml-auto"
|
||||
variant="success"
|
||||
v-t="'retry'"
|
||||
class="ml-auto"
|
||||
@click="tryToReconnect()"
|
||||
/>
|
||||
</div>
|
||||
|
@ -40,39 +42,41 @@ import { mapGetters } from 'vuex'
|
|||
import api from '@/api'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'ReconnectingDisplay',
|
||||
|
||||
components: {
|
||||
LoginView
|
||||
LoginView,
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
status: 'reconnecting',
|
||||
origin: undefined
|
||||
origin: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['reconnecting'])
|
||||
...mapGetters(['reconnecting']),
|
||||
},
|
||||
|
||||
methods: {
|
||||
tryToReconnect (initialDelay = 0) {
|
||||
tryToReconnect(initialDelay = 0) {
|
||||
this.status = 'reconnecting'
|
||||
api.tryToReconnect({ ...this.reconnecting, initialDelay }).then(() => {
|
||||
this.status = 'success'
|
||||
}).catch(() => {
|
||||
this.status = 'failed'
|
||||
})
|
||||
}
|
||||
api
|
||||
.tryToReconnect({ ...this.reconnecting, initialDelay })
|
||||
.then(() => {
|
||||
this.status = 'success'
|
||||
})
|
||||
.catch(() => {
|
||||
this.status = 'failed'
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
created() {
|
||||
this.origin = this.reconnecting.origin || 'unknown'
|
||||
this.tryToReconnect(this.reconnecting.initialDelay)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<BOverlay
|
||||
variant="white" opacity="0.75"
|
||||
variant="white"
|
||||
opacity="0.75"
|
||||
no-center
|
||||
:show="waiting || reconnecting || error !== null"
|
||||
>
|
||||
|
@ -20,7 +21,12 @@
|
|||
|
||||
<script>
|
||||
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'
|
||||
|
||||
export default {
|
||||
|
@ -31,13 +37,13 @@ export default {
|
|||
WarningDisplay,
|
||||
WaitingDisplay,
|
||||
ReconnectingDisplay,
|
||||
QueryHeader
|
||||
QueryHeader,
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['waiting', 'reconnecting', 'error', 'currentRequest']),
|
||||
|
||||
component () {
|
||||
component() {
|
||||
const { error, reconnecting, currentRequest: request } = this
|
||||
|
||||
if (error) {
|
||||
|
@ -49,8 +55,8 @@ export default {
|
|||
} else {
|
||||
return { name: 'WaitingDisplay', request }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -81,14 +87,14 @@ export default {
|
|||
}
|
||||
|
||||
.card-footer {
|
||||
padding: .5rem .75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: .5rem .75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
||||
<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 -->
|
||||
<BProgress
|
||||
v-if="progress" class="my-4"
|
||||
:max="progress.max" height=".5rem"
|
||||
>
|
||||
<BProgress v-if="progress" class="my-4" :max="progress.max" height=".5rem">
|
||||
<BProgressBar variant="success" :value="progress.values[0]" />
|
||||
<BProgressBar variant="warning" :value="progress.values[1]" animated />
|
||||
<BProgressBar variant="secondary" :value="progress.values[2]" striped />
|
||||
|
@ -16,8 +16,11 @@
|
|||
<YSpinner v-else class="my-4" />
|
||||
|
||||
<MessageListGroup
|
||||
v-if="hasMessages" :messages="request.messages"
|
||||
bordered fixed-height auto-scroll
|
||||
v-if="hasMessages"
|
||||
:messages="request.messages"
|
||||
bordered
|
||||
fixed-height
|
||||
auto-scroll
|
||||
:limit="100"
|
||||
/>
|
||||
</BCardBody>
|
||||
|
@ -30,26 +33,26 @@ export default {
|
|||
name: 'WaitingDisplay',
|
||||
|
||||
components: {
|
||||
MessageListGroup
|
||||
MessageListGroup,
|
||||
},
|
||||
|
||||
props: {
|
||||
request: { type: Object, required: true }
|
||||
request: { type: Object, required: true },
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasMessages () {
|
||||
hasMessages() {
|
||||
return this.request.messages && this.request.messages.length > 0
|
||||
},
|
||||
|
||||
progress () {
|
||||
progress() {
|
||||
const progress = this.request.progress
|
||||
if (!progress) return null
|
||||
return {
|
||||
values: progress,
|
||||
max: progress.reduce((sum, value) => (sum + value), 0)
|
||||
max: progress.reduce((sum, value) => sum + value, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -6,10 +6,7 @@
|
|||
</BCardBody>
|
||||
|
||||
<BCardFooter footer-bg-variant="warning">
|
||||
<BButton
|
||||
variant="light" size="sm"
|
||||
v-t="'ok'" @click="dismiss"
|
||||
/>
|
||||
<BButton variant="light" size="sm" v-t="'ok'" @click="dismiss" />
|
||||
</BCardFooter>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -19,27 +16,27 @@ export default {
|
|||
name: 'WarningDisplay',
|
||||
|
||||
props: {
|
||||
request: { type: Object, required: true }
|
||||
request: { type: Object, required: true },
|
||||
},
|
||||
|
||||
computed: {
|
||||
warning () {
|
||||
warning() {
|
||||
const messages = this.request.messages
|
||||
return messages[messages.length - 1]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
dismiss () {
|
||||
dismiss() {
|
||||
this.$store.dispatch('DISMISS_WARNING', this.request)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-body {
|
||||
padding-bottom: 1.5rem !important;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.card-body {
|
||||
padding-bottom: 1.5rem !important;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
<template>
|
||||
<ViewSearch
|
||||
:items="apps" :filtered-items="filteredApps" items-name="apps"
|
||||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
:items="apps"
|
||||
:filtered-items="filteredApps"
|
||||
items-name="apps"
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
>
|
||||
<template #top-bar>
|
||||
<div id="view-top-bar">
|
||||
|
@ -11,11 +14,17 @@
|
|||
<YIcon iname="search" />
|
||||
</BInputGroupPrepend>
|
||||
<BFormInput
|
||||
id="search-input" :placeholder="$t('search.for', { items: $tc('items.apps', 2) })"
|
||||
:value="search" @input="updateQuery('search', $event)"
|
||||
id="search-input"
|
||||
:placeholder="$t('search.for', { items: $tc('items.apps', 2) })"
|
||||
:value="search"
|
||||
@input="updateQuery('search', $event)"
|
||||
/>
|
||||
<BInputGroupAppend>
|
||||
<BFormSelect :value="quality" :options="qualityOptions" @change="updateQuery('quality', $event)" />
|
||||
<BFormSelect
|
||||
:value="quality"
|
||||
:options="qualityOptions"
|
||||
@change="updateQuery('quality', $event)"
|
||||
/>
|
||||
</BInputGroupAppend>
|
||||
</BInputGroup>
|
||||
|
||||
|
@ -24,9 +33,17 @@
|
|||
<BInputGroupPrepend is-text>
|
||||
<YIcon iname="filter" />
|
||||
</BInputGroupPrepend>
|
||||
<BFormSelect :value="category" :options="categories" @change="updateQuery('category', $event)" />
|
||||
<BFormSelect
|
||||
:value="category"
|
||||
:options="categories"
|
||||
@change="updateQuery('category', $event)"
|
||||
/>
|
||||
<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') }}
|
||||
</BButton>
|
||||
</BInputGroupAppend>
|
||||
|
@ -34,16 +51,20 @@
|
|||
|
||||
<!-- CATEGORIES SUBTAGS -->
|
||||
<BInputGroup v-if="subtags" class="mt-3 subtags">
|
||||
<BInputGroupPrepend is-text>
|
||||
Subtags
|
||||
</BInputGroupPrepend>
|
||||
<BInputGroupPrepend is-text> Subtags </BInputGroupPrepend>
|
||||
<BFormRadioGroup
|
||||
id="subtags-radio" name="subtags"
|
||||
:checked="subtag" :options="subtags" @change="updateQuery('subtag', $event)"
|
||||
buttons button-variant="outline-secondary"
|
||||
id="subtags-radio"
|
||||
name="subtags"
|
||||
:checked="subtag"
|
||||
:options="subtags"
|
||||
@change="updateQuery('subtag', $event)"
|
||||
buttons
|
||||
button-variant="outline-secondary"
|
||||
/>
|
||||
<BFormSelect
|
||||
id="subtags-select" :value="subtag" :options="subtags"
|
||||
id="subtags-select"
|
||||
:value="subtag"
|
||||
:options="subtags"
|
||||
@change="updateQuery('subtag', $event)"
|
||||
/>
|
||||
</BInputGroup>
|
||||
|
@ -53,8 +74,10 @@
|
|||
<!-- CATEGORIES CARDS -->
|
||||
<BCardGroup v-if="category === null" deck tag="ul">
|
||||
<BCard
|
||||
v-for="cat in categories.slice(1)" :key="cat.value"
|
||||
tag="li" class="category-card"
|
||||
v-for="cat in categories.slice(1)"
|
||||
:key="cat.value"
|
||||
tag="li"
|
||||
class="category-card"
|
||||
>
|
||||
<BCardTitle>
|
||||
<BLink @click="updateQuery('category', cat.value)" class="card-link">
|
||||
|
@ -68,33 +91,55 @@
|
|||
<!-- APPS CARDS -->
|
||||
<CardDeckFeed v-else>
|
||||
<BCard
|
||||
v-for="(app, i) in filteredApps" :key="app.id"
|
||||
tag="article" :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"
|
||||
v-for="(app, i) in filteredApps"
|
||||
:key="app.id"
|
||||
tag="article"
|
||||
: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">
|
||||
<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>
|
||||
<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 }}
|
||||
</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
|
||||
v-if="app.state !== 'working'"
|
||||
: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' -->
|
||||
{{ $t('app_state_' + app.state) }}
|
||||
</BBadge>
|
||||
|
||||
<YIcon
|
||||
v-if="app.high_quality" iname="star" class="star"
|
||||
v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)"
|
||||
v-if="app.high_quality"
|
||||
iname="star"
|
||||
class="star"
|
||||
v-b-popover.hover.bottom="
|
||||
$t(`app_state_highquality_explanation`)
|
||||
"
|
||||
/>
|
||||
</small>
|
||||
</BCardTitle>
|
||||
|
@ -103,8 +148,14 @@
|
|||
{{ app.manifest.description }}
|
||||
</BCardText>
|
||||
|
||||
<BCardText 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')">
|
||||
<BCardText
|
||||
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') }}
|
||||
</span>
|
||||
</BCardText>
|
||||
|
@ -125,37 +176,52 @@
|
|||
<template #bot>
|
||||
<!-- INSTALL CUSTOM APP -->
|
||||
<CardForm
|
||||
:title="$t('custom_app_install')" icon="download"
|
||||
@submit.prevent="onCustomInstallClick" :submit-text="$t('install')"
|
||||
:validation="$v" class="mt-5"
|
||||
:title="$t('custom_app_install')"
|
||||
icon="download"
|
||||
@submit.prevent="onCustomInstallClick"
|
||||
:submit-text="$t('install')"
|
||||
:validation="$v"
|
||||
class="mt-5"
|
||||
>
|
||||
<template #disclaimer>
|
||||
<div class="alert alert-warning">
|
||||
<YIcon iname="exclamation-triangle" /> {{ $t('confirm_install_custom_app') }}
|
||||
<YIcon iname="exclamation-triangle" />
|
||||
{{ $t('confirm_install_custom_app') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
<!-- CUSTOM SKELETON -->
|
||||
<template #skeleton>
|
||||
<BCardGroup deck>
|
||||
<BCard
|
||||
v-for="i in 15" :key="i"
|
||||
no-body style="min-height: 10rem;"
|
||||
>
|
||||
<BCard v-for="i in 15" :key="i" no-body style="min-height: 10rem">
|
||||
<div class="d-flex w-100 mt-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>
|
||||
<BSkeleton
|
||||
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>
|
||||
</BCardGroup>
|
||||
</template>
|
||||
|
@ -173,21 +239,19 @@ export default {
|
|||
name: 'AppCatalog',
|
||||
|
||||
components: {
|
||||
CardDeckFeed
|
||||
CardDeckFeed,
|
||||
},
|
||||
|
||||
props: {
|
||||
search: { type: String, default: '' },
|
||||
quality: { type: String, default: 'decent_quality' },
|
||||
category: { type: String, default: null },
|
||||
subtag: { type: String, default: 'all' }
|
||||
subtag: { type: String, default: 'all' },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', 'apps/catalog?full&with_categories&with_antifeatures']
|
||||
],
|
||||
queries: [['GET', 'apps/catalog?full&with_categories&with_antifeatures']],
|
||||
|
||||
// Data
|
||||
apps: undefined,
|
||||
|
@ -197,13 +261,16 @@ export default {
|
|||
// Filtering options
|
||||
qualityOptions: [
|
||||
{ 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: 'all', text: this.$i18n.t('all_apps') }
|
||||
{ value: 'all', text: this.$i18n.t('all_apps') },
|
||||
],
|
||||
categories: [
|
||||
{ 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
|
||||
],
|
||||
|
||||
|
@ -213,31 +280,33 @@ export default {
|
|||
label: this.$i18n.t('url'),
|
||||
props: {
|
||||
id: 'custom-install',
|
||||
placeholder: 'https://some.git.forge.tld/USER/REPOSITORY'
|
||||
}
|
||||
placeholder: 'https://some.git.forge.tld/USER/REPOSITORY',
|
||||
},
|
||||
},
|
||||
url: ''
|
||||
}
|
||||
url: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredApps () {
|
||||
filteredApps() {
|
||||
if (!this.apps || this.category === null) return
|
||||
const search = this.search.toLowerCase()
|
||||
|
||||
if (this.quality === 'all' && this.category === 'all' && search === '') {
|
||||
return this.apps
|
||||
}
|
||||
const filtered = this.apps.filter(app => {
|
||||
const filtered = this.apps.filter((app) => {
|
||||
// app doesn't match quality filter
|
||||
if (this.quality !== 'all' && !app[this.quality]) return false
|
||||
// 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') {
|
||||
const appMatchSubtag = this.subtag === 'others'
|
||||
? app.subtags.length === 0
|
||||
: app.subtags.includes(this.subtag)
|
||||
const appMatchSubtag =
|
||||
this.subtag === 'others'
|
||||
? app.subtags.length === 0
|
||||
: app.subtags.includes(this.subtag)
|
||||
// app doesn't match subtag filter
|
||||
if (!appMatchSubtag) return false
|
||||
}
|
||||
|
@ -248,13 +317,15 @@ export default {
|
|||
return filtered.length ? filtered : null
|
||||
},
|
||||
|
||||
subtags () {
|
||||
subtags() {
|
||||
// build an options array for subtags v-model/options
|
||||
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) {
|
||||
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: this.$i18n.t('others'), value: 'others' })
|
||||
|
@ -262,21 +333,22 @@ export default {
|
|||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
validations: {
|
||||
customInstall: {
|
||||
url: { required, appRepoUrl }
|
||||
}
|
||||
url: { required, appRepoUrl },
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (data) {
|
||||
onQueriesResponse(data) {
|
||||
const apps = []
|
||||
for (const key in data.apps) {
|
||||
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.decent_quality = app.working && app.level > 4
|
||||
app.high_quality = app.working && app.level >= 8
|
||||
|
@ -295,57 +367,71 @@ export default {
|
|||
app.state,
|
||||
app.manifest.name,
|
||||
app.manifest.description,
|
||||
app.potential_alternative_to.join(' ')
|
||||
].join(' ').toLowerCase()
|
||||
app.potential_alternative_to.join(' '),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
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
|
||||
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
|
||||
this.$router.replace({
|
||||
query: {
|
||||
...this.$route.query,
|
||||
// allow search without selecting a category
|
||||
category: this.$route.query.category || 'all',
|
||||
[key]: value
|
||||
}
|
||||
[key]: value,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// INSTALL APP
|
||||
async onInstallClick (appId) {
|
||||
async onInstallClick(appId) {
|
||||
const app = this.apps.find((app) => app.id === appId)
|
||||
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
|
||||
}
|
||||
this.$router.push({ name: 'app-install', params: { id: app.id } })
|
||||
},
|
||||
|
||||
// INSTALL CUSTOM APP
|
||||
async onCustomInstallClick () {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_custom_app'))
|
||||
async onCustomInstallClick() {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_install_custom_app'),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
const url = this.customInstall.url
|
||||
this.$router.push({
|
||||
name: 'app-install-custom',
|
||||
params: { id: url.endsWith('/') ? url : url + '/' }
|
||||
params: { id: url.endsWith('/') ? url : url + '/' },
|
||||
})
|
||||
},
|
||||
|
||||
randint
|
||||
randint,
|
||||
},
|
||||
|
||||
mixins: [validationMixin]
|
||||
mixins: [validationMixin],
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -364,7 +450,7 @@ export default {
|
|||
|
||||
.subtags {
|
||||
#subtags-radio {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
@include media-breakpoint-up(md) {
|
||||
#subtags-radio {
|
||||
|
@ -418,7 +504,7 @@ export default {
|
|||
|
||||
// not maintained info
|
||||
.alert-warning {
|
||||
font-size: .75em;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.star {
|
||||
|
@ -450,7 +536,7 @@ export default {
|
|||
}
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
<template>
|
||||
<ViewBase
|
||||
:queries="queries" @queries-response="onQueriesResponse" :loading="loading"
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
:loading="loading"
|
||||
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">
|
||||
<h2 v-t="'app.doc.notifications.post_install'" class="md-m-0" />
|
||||
<BButton
|
||||
|
@ -18,12 +29,24 @@
|
|||
</div>
|
||||
|
||||
<VueShowdown
|
||||
v-for="[name, notif] in app.doc.notifications.postInstall" :key="name"
|
||||
:markdown="notif" flavor="github" :options="{ headerLevelStart: 4 }"
|
||||
v-for="[name, notif] in app.doc.notifications.postInstall"
|
||||
:key="name"
|
||||
:markdown="notif"
|
||||
flavor="github"
|
||||
:options="{ headerLevelStart: 4 }"
|
||||
/>
|
||||
</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">
|
||||
<h2 v-t="'app.doc.notifications.post_upgrade'" class="md-m-0" />
|
||||
<BButton
|
||||
|
@ -38,8 +61,11 @@
|
|||
</div>
|
||||
|
||||
<VueShowdown
|
||||
v-for="[name, notif] in app.doc.notifications.postUpgrade" :key="name"
|
||||
:markdown="notif" flavor="github" :options="{ headerLevelStart: 4 }"
|
||||
v-for="[name, notif] in app.doc.notifications.postUpgrade"
|
||||
:key="name"
|
||||
:markdown="notif"
|
||||
flavor="github"
|
||||
:options="{ headerLevelStart: 4 }"
|
||||
/>
|
||||
</YAlert>
|
||||
|
||||
|
@ -56,8 +82,10 @@
|
|||
|
||||
<BButton
|
||||
v-if="app.url"
|
||||
:href="app.url" target="_blank"
|
||||
variant="success" class="ml-auto mr-2"
|
||||
:href="app.url"
|
||||
target="_blank"
|
||||
variant="success"
|
||||
class="ml-auto mr-2"
|
||||
>
|
||||
<YIcon iname="external-link" />
|
||||
{{ $t('app.open_this_app') }}
|
||||
|
@ -75,10 +103,11 @@
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<strong v-t="'app.potential_alternative_to'" /> {{ app.alternativeTo }}
|
||||
<strong v-t="'app.potential_alternative_to'" />
|
||||
{{ app.alternativeTo }}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
|
@ -92,10 +121,7 @@
|
|||
<VueShowdown :markdown="app.description" flavor="github" />
|
||||
</section>
|
||||
|
||||
<YAlert
|
||||
v-if="config_panel_err"
|
||||
class="mb-4" variant="danger" icon="bug"
|
||||
>
|
||||
<YAlert v-if="config_panel_err" class="mb-4" variant="danger" icon="bug">
|
||||
<p>{{ $t('app.info.config_panel_error') }}</p>
|
||||
<p>{{ config_panel_err }}</p>
|
||||
<p>{{ $t('app.info.config_panel_error_please_report') }}</p>
|
||||
|
@ -106,25 +132,38 @@
|
|||
<!-- OPERATIONS TAB -->
|
||||
<template v-if="currentTab === 'operations'" #tab-top>
|
||||
<!-- 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
|
||||
v-for="(perm, i) in app.permissions" :key="i"
|
||||
:label="perm.title" :label-for="'perm-' + i"
|
||||
label-cols="0" label-class="" class="m-0"
|
||||
:validation="$v.form.labels.$each[i] "
|
||||
v-for="(perm, i) in app.permissions"
|
||||
:key="i"
|
||||
:label="perm.title"
|
||||
:label-for="'perm-' + i"
|
||||
label-cols="0"
|
||||
label-class=""
|
||||
class="m-0"
|
||||
:validation="$v.form.labels.$each[i]"
|
||||
>
|
||||
<template #default="{ self }">
|
||||
<BInputGroup>
|
||||
<InputItem
|
||||
:state="self.state" v-model="form.labels[i].label"
|
||||
:id="'perm' + i" :aria-describedby="'perm-' + i + '_group__BV_description_'"
|
||||
:state="self.state"
|
||||
v-model="form.labels[i].label"
|
||||
:id="'perm' + i"
|
||||
:aria-describedby="'perm-' + i + '_group__BV_description_'"
|
||||
/>
|
||||
<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>
|
||||
<BButton
|
||||
variant="info" v-t="'save'"
|
||||
variant="info"
|
||||
v-t="'save'"
|
||||
@click="changeLabel(perm.name, form.labels[i])"
|
||||
/>
|
||||
</BInputGroupAppend>
|
||||
|
@ -139,43 +178,52 @@
|
|||
</template>
|
||||
</FormField>
|
||||
</BFormGroup>
|
||||
<hr>
|
||||
<hr />
|
||||
|
||||
<!-- PERMISSIONS -->
|
||||
<BFormGroup
|
||||
:label="$t('app_info_access_desc')" label-for="permissions"
|
||||
label-class="font-weight-bold" label-cols-lg="0"
|
||||
:label="$t('app_info_access_desc')"
|
||||
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
|
||||
size="sm" :to="{ name: 'group-list'}" variant="info"
|
||||
size="sm"
|
||||
:to="{ name: 'group-list' }"
|
||||
variant="info"
|
||||
class="ml-2"
|
||||
>
|
||||
<YIcon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
|
||||
<YIcon iname="key-modern" />
|
||||
{{ $t('groups_and_permissions_manage') }}
|
||||
</BButton>
|
||||
</BFormGroup>
|
||||
<hr>
|
||||
<hr />
|
||||
|
||||
<!-- CHANGE URL -->
|
||||
<BFormGroup
|
||||
:label="$t('app_info_changeurl_desc')" label-for="input-url"
|
||||
:label-cols-lg="app.supports_change_url ? 0 : 0" label-class="font-weight-bold"
|
||||
:label="$t('app_info_changeurl_desc')"
|
||||
label-for="input-url"
|
||||
:label-cols-lg="app.supports_change_url ? 0 : 0"
|
||||
label-class="font-weight-bold"
|
||||
v-if="app.is_webapp"
|
||||
>
|
||||
<BInputGroup v-if="app.supports_change_url">
|
||||
<BInputGroupPrepend is-text>
|
||||
https://
|
||||
</BInputGroupPrepend>
|
||||
<BInputGroupPrepend is-text> https:// </BInputGroupPrepend>
|
||||
|
||||
<BInputGroupPrepend class="flex-grow-1">
|
||||
<BFormSelect v-model="form.url.domain" :options="domains" />
|
||||
</BInputGroupPrepend>
|
||||
|
||||
<BInputGroupPrepend is-text>
|
||||
/
|
||||
</BInputGroupPrepend>
|
||||
<BInputGroupPrepend is-text> / </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>
|
||||
<BButton @click="changeUrl" variant="info" v-t="'save'" />
|
||||
|
@ -183,25 +231,36 @@
|
|||
</BInputGroup>
|
||||
|
||||
<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>
|
||||
</BFormGroup>
|
||||
<hr v-if="app.is_webapp">
|
||||
<hr v-if="app.is_webapp" />
|
||||
|
||||
<!-- MAKE DEFAULT -->
|
||||
<BFormGroup
|
||||
:label="$t('app_info_default_desc', { domain: app.domain })" label-for="main-domain"
|
||||
label-class="font-weight-bold" label-cols-md="4"
|
||||
:label="$t('app_info_default_desc', { domain: app.domain })"
|
||||
label-for="main-domain"
|
||||
label-class="font-weight-bold"
|
||||
label-cols-md="4"
|
||||
v-if="app.is_webapp"
|
||||
>
|
||||
<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') }}
|
||||
</BButton>
|
||||
</template>
|
||||
|
||||
<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') }}
|
||||
</BButton>
|
||||
</template>
|
||||
|
@ -211,12 +270,10 @@
|
|||
|
||||
<BCard v-if="app && app.doc.admin.length" no-body>
|
||||
<BTabs card fill pills>
|
||||
<BTab
|
||||
v-for="[name, content] in app.doc.admin" :key="name"
|
||||
>
|
||||
<BTab v-for="[name, content] in app.doc.admin" :key="name">
|
||||
<template #title>
|
||||
<YIcon iname="book" class="mr-2" />
|
||||
{{ name === "admin" ? $t('app.doc.admin.title') : name }}
|
||||
{{ name === 'admin' ? $t('app.doc.admin.title') : name }}
|
||||
</template>
|
||||
<VueShowdown :markdown="content" flavor="github" />
|
||||
</BTab>
|
||||
|
@ -225,21 +282,34 @@
|
|||
|
||||
<YCard
|
||||
v-if="app && app.integration"
|
||||
id="app-integration" :title="$t('app.integration.title')"
|
||||
collapsable collapsed no-body
|
||||
id="app-integration"
|
||||
:title="$t('app.integration.title')"
|
||||
collapsable
|
||||
collapsed
|
||||
no-body
|
||||
>
|
||||
<BListGroup flush>
|
||||
<YListGroupItem variant="info">
|
||||
{{ $t('app.integration.archs') }} {{ app.integration.archs }}
|
||||
</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}`) }}
|
||||
</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}`) }}
|
||||
</YListGroupItem>
|
||||
<YListGroupItem variant="info">
|
||||
{{ $t(`app.integration.multi_instance.${app.integration.multi_instance}`) }}
|
||||
{{
|
||||
$t(
|
||||
`app.integration.multi_instance.${app.integration.multi_instance}`,
|
||||
)
|
||||
}}
|
||||
</YListGroupItem>
|
||||
<YListGroupItem variant="info">
|
||||
{{ $t('app.integration.resources', app.integration.resources) }}
|
||||
|
@ -249,8 +319,12 @@
|
|||
|
||||
<YCard
|
||||
v-if="app"
|
||||
id="app-links" icon="link" :title="$t('app.links.title')"
|
||||
collapsable collapsed no-body
|
||||
id="app-links"
|
||||
icon="link"
|
||||
:title="$t('app.links.title')"
|
||||
collapsable
|
||||
collapsed
|
||||
no-body
|
||||
>
|
||||
<BListGroup flush>
|
||||
<YListGroupItem v-for="[key, link] in app.links" :key="key" no-status>
|
||||
|
@ -264,8 +338,11 @@
|
|||
|
||||
<BModal
|
||||
v-if="app"
|
||||
id="uninstall-modal" :title="$t('confirm_uninstall', { name: id })"
|
||||
header-bg-variant="warning" :body-class="{ 'd-none': !app.supports_purge }" body-bg-variant=""
|
||||
id="uninstall-modal"
|
||||
:title="$t('confirm_uninstall', { name: id })"
|
||||
header-bg-variant="warning"
|
||||
:body-class="{ 'd-none': !app.supports_purge }"
|
||||
body-bg-variant=""
|
||||
@ok="uninstall"
|
||||
>
|
||||
<BFormGroup v-if="app.supports_purge">
|
||||
|
@ -293,7 +370,7 @@ import { isEmptyValue } from '@/helpers/commons'
|
|||
import {
|
||||
formatFormData,
|
||||
formatI18nField,
|
||||
formatYunoHostConfigPanels
|
||||
formatYunoHostConfigPanels,
|
||||
} from '@/helpers/yunohostArguments'
|
||||
import ConfigPanels from '@/components/ConfigPanels.vue'
|
||||
|
||||
|
@ -301,19 +378,19 @@ export default {
|
|||
name: 'AppInfo',
|
||||
|
||||
components: {
|
||||
ConfigPanels
|
||||
ConfigPanels,
|
||||
},
|
||||
|
||||
props: {
|
||||
id: { type: String, required: true }
|
||||
id: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', `apps/${this.id}?full`],
|
||||
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
|
||||
['GET', { uri: 'domains' }]
|
||||
['GET', { uri: 'domains' }],
|
||||
],
|
||||
loading: true,
|
||||
app: undefined,
|
||||
|
@ -326,62 +403,66 @@ export default {
|
|||
{
|
||||
hasApplyButton: false,
|
||||
id: 'operations',
|
||||
name: this.$i18n.t('operations')
|
||||
}
|
||||
name: this.$i18n.t('operations'),
|
||||
},
|
||||
],
|
||||
validations: {}
|
||||
validations: {},
|
||||
},
|
||||
doc: undefined
|
||||
doc: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['domains']),
|
||||
|
||||
currentTab () {
|
||||
currentTab() {
|
||||
return this.$route.params.tabId
|
||||
},
|
||||
|
||||
allowedGroups () {
|
||||
allowedGroups() {
|
||||
if (!this.app) return
|
||||
return this.app.permissions[0].allowed
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
validations () {
|
||||
validations() {
|
||||
return {
|
||||
form: {
|
||||
labels: {
|
||||
$each: { label: { required } }
|
||||
$each: { label: { required } },
|
||||
},
|
||||
url: { path: { required } }
|
||||
}
|
||||
url: { path: { required } },
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
appLinksIcons (linkType) {
|
||||
const linksIcons = {
|
||||
license: 'institution',
|
||||
website: 'globe',
|
||||
admindoc: 'book',
|
||||
userdoc: 'book',
|
||||
code: 'code',
|
||||
package: 'code',
|
||||
package_license: 'institution',
|
||||
forum: 'comments'
|
||||
}
|
||||
return linksIcons[linkType]
|
||||
appLinksIcons(linkType) {
|
||||
const linksIcons = {
|
||||
license: 'institution',
|
||||
website: 'globe',
|
||||
admindoc: 'book',
|
||||
userdoc: 'book',
|
||||
code: 'code',
|
||||
package: 'code',
|
||||
package_license: 'institution',
|
||||
forum: 'comments',
|
||||
}
|
||||
return linksIcons[linkType]
|
||||
},
|
||||
|
||||
async onQueriesResponse (app) {
|
||||
async onQueriesResponse(app) {
|
||||
const form = { labels: [] }
|
||||
|
||||
const mainPermission = app.permissions[this.id + '.main']
|
||||
mainPermission.name = this.id + '.main'
|
||||
mainPermission.title = this.$i18n.t('permission_main')
|
||||
mainPermission.tileAvailable = mainPermission.url !== null && !mainPermission.url.startsWith('re:')
|
||||
form.labels.push({ label: mainPermission.label, show_tile: mainPermission.show_tile })
|
||||
mainPermission.tileAvailable =
|
||||
mainPermission.url !== null && !mainPermission.url.startsWith('re:')
|
||||
form.labels.push({
|
||||
label: mainPermission.label,
|
||||
show_tile: mainPermission.show_tile,
|
||||
})
|
||||
|
||||
const permissions = [mainPermission]
|
||||
for (const [name, perm] of Object.entries(app.permissions)) {
|
||||
|
@ -391,7 +472,7 @@ export default {
|
|||
name,
|
||||
label: perm.sublabel,
|
||||
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 })
|
||||
}
|
||||
|
@ -400,148 +481,214 @@ export default {
|
|||
|
||||
const { DESCRIPTION, ADMIN, ...doc } = app.manifest.doc
|
||||
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 = {
|
||||
id: this.id,
|
||||
version: app.version,
|
||||
label: mainPermission.label,
|
||||
domain: app.settings.domain,
|
||||
alternativeTo: app.from_catalog.potential_alternative_to?.length
|
||||
? app.from_catalog.potential_alternative_to.join(this.$i18n.t('words.separator'))
|
||||
: 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 }
|
||||
}
|
||||
? app.from_catalog.potential_alternative_to.join(
|
||||
this.$i18n.t('words.separator'),
|
||||
)
|
||||
: 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: [
|
||||
['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_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),
|
||||
doc: {
|
||||
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
|
||||
? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => {
|
||||
return [key, formatI18nField(content)]
|
||||
})
|
||||
: []
|
||||
return [key, formatI18nField(content)]
|
||||
})
|
||||
: [],
|
||||
},
|
||||
admin: [
|
||||
['admin', formatI18nField(ADMIN)],
|
||||
...Object.keys(doc).sort().map((key) => [key.charAt(0) + key.slice(1).toLowerCase(), formatI18nField(doc[key])])
|
||||
].filter((doc) => doc[1])
|
||||
...Object.keys(doc)
|
||||
.sort()
|
||||
.map((key) => [
|
||||
key.charAt(0) + key.slice(1).toLowerCase(),
|
||||
formatI18nField(doc[key]),
|
||||
]),
|
||||
].filter((doc) => doc[1]),
|
||||
},
|
||||
is_webapp: app.is_webapp,
|
||||
is_default: app.is_default,
|
||||
supports_change_url: app.supports_change_url,
|
||||
supports_config_panel: app.supports_config_panel,
|
||||
supports_purge: app.supports_purge,
|
||||
permissions
|
||||
permissions,
|
||||
}
|
||||
if (app.settings.domain && app.settings.path) {
|
||||
this.app.url = 'https://' + app.settings.domain + app.settings.path
|
||||
form.url = {
|
||||
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
|
||||
}
|
||||
|
||||
if (app.supports_config_panel) {
|
||||
await api.get(`apps/${this.id}/config?full`).then((config) => {
|
||||
const config_ = formatYunoHostConfigPanels(config)
|
||||
// reinject 'operations' fake config tab
|
||||
config_.panels.unshift(this.config.panels[0])
|
||||
this.config = config_
|
||||
}).catch((err) => {
|
||||
this.config_panel_err = err.message
|
||||
})
|
||||
await api
|
||||
.get(`apps/${this.id}/config?full`)
|
||||
.then((config) => {
|
||||
const config_ = formatYunoHostConfigPanels(config)
|
||||
// reinject 'operations' fake config tab
|
||||
config_.panels.unshift(this.config.panels[0])
|
||||
this.config = config_
|
||||
})
|
||||
.catch((err) => {
|
||||
this.config_panel_err = err.message
|
||||
})
|
||||
}
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async onConfigSubmit ({ id, form, action, name }) {
|
||||
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||
|
||||
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)
|
||||
async onConfigSubmit({ id, form, action, name }) {
|
||||
const args = await formatFormData(form, {
|
||||
removeEmpty: false,
|
||||
removeNull: true,
|
||||
})
|
||||
|
||||
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'
|
||||
api.put(
|
||||
'users/permissions/' + permName,
|
||||
data,
|
||||
{ key: 'apps.change_label', prevName: this.app.label, nextName: data.label }
|
||||
).then(this.$refs.view.fetchQueries)
|
||||
api
|
||||
.put('users/permissions/' + permName, data, {
|
||||
key: 'apps.change_label',
|
||||
prevName: this.app.label,
|
||||
nextName: data.label,
|
||||
})
|
||||
.then(this.$refs.view.fetchQueries)
|
||||
},
|
||||
|
||||
async changeUrl () {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_change_url'))
|
||||
async changeUrl() {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_app_change_url'),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
const { domain, path } = this.form.url
|
||||
api.put(
|
||||
`apps/${this.id}/changeurl`,
|
||||
{ domain, path: '/' + path },
|
||||
{ key: 'apps.change_url', name: this.app.label }
|
||||
).then(this.$refs.view.fetchQueries)
|
||||
api
|
||||
.put(
|
||||
`apps/${this.id}/changeurl`,
|
||||
{ domain, path: '/' + path },
|
||||
{ key: 'apps.change_url', name: this.app.label },
|
||||
)
|
||||
.then(this.$refs.view.fetchQueries)
|
||||
},
|
||||
|
||||
async setAsDefaultDomain (undo = false) {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_default'))
|
||||
async setAsDefaultDomain(undo = false) {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_app_default'),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
api.put(
|
||||
`apps/${this.id}/default${undo ? '?undo' : ''}`,
|
||||
{},
|
||||
{ key: 'apps.set_default', name: this.app.label, domain: this.app.domain }
|
||||
).then(this.$refs.view.fetchQueries)
|
||||
api
|
||||
.put(
|
||||
`apps/${this.id}/default${undo ? '?undo' : ''}`,
|
||||
{},
|
||||
{
|
||||
key: 'apps.set_default',
|
||||
name: this.app.label,
|
||||
domain: this.app.domain,
|
||||
},
|
||||
)
|
||||
.then(this.$refs.view.fetchQueries)
|
||||
},
|
||||
|
||||
async dismissNotification (name) {
|
||||
api.put(
|
||||
`apps/${this.id}/dismiss_notification/${name}`,
|
||||
{},
|
||||
{ key: 'apps.dismiss_notification', name: this.app.label }
|
||||
).then(this.$refs.view.fetchQueries)
|
||||
async dismissNotification(name) {
|
||||
api
|
||||
.put(
|
||||
`apps/${this.id}/dismiss_notification/${name}`,
|
||||
{},
|
||||
{ key: 'apps.dismiss_notification', name: this.app.label },
|
||||
)
|
||||
.then(this.$refs.view.fetchQueries)
|
||||
},
|
||||
|
||||
async uninstall () {
|
||||
async uninstall() {
|
||||
const data = this.purge === true ? { purge: 1 } : {}
|
||||
api.delete('apps/' + this.id, data, { key: 'apps.uninstall', name: this.app.label }).then(() => {
|
||||
this.$router.push({ name: 'app-list' })
|
||||
})
|
||||
}
|
||||
api
|
||||
.delete('apps/' + this.id, data, {
|
||||
key: 'apps.uninstall',
|
||||
name: this.app.label,
|
||||
})
|
||||
.then(() => {
|
||||
this.$router.push({ name: 'app-list' })
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [validationMixin]
|
||||
mixins: [validationMixin],
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -9,8 +9,10 @@
|
|||
|
||||
<BButton
|
||||
v-if="app.demo"
|
||||
:href="app.demo" target="_blank"
|
||||
variant="primary" class="ml-auto"
|
||||
:href="app.demo"
|
||||
target="_blank"
|
||||
variant="primary"
|
||||
class="ml-auto"
|
||||
>
|
||||
<YIcon iname="external-link" />
|
||||
{{ $t('app.install.try_demo') }}
|
||||
|
@ -18,7 +20,7 @@
|
|||
</div>
|
||||
|
||||
<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">
|
||||
{{ $t('app.potential_alternative_to') }} {{ app.alternativeTo }}
|
||||
|
@ -30,27 +32,42 @@
|
|||
<BImg
|
||||
v-if="app.screenshot"
|
||||
:src="app.screenshot"
|
||||
aria-hidden="true" class="d-block" fluid
|
||||
aria-hidden="true"
|
||||
class="d-block"
|
||||
fluid
|
||||
/>
|
||||
</section>
|
||||
|
||||
<YCard
|
||||
v-if="app.integration"
|
||||
id="app-integration" :title="$t('app.integration.title')"
|
||||
collapsable collapsed no-body
|
||||
id="app-integration"
|
||||
:title="$t('app.integration.title')"
|
||||
collapsable
|
||||
collapsed
|
||||
no-body
|
||||
>
|
||||
<BListGroup flush>
|
||||
<YListGroupItem variant="info">
|
||||
{{ $t('app.integration.archs') }} {{ app.integration.archs }}
|
||||
</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}`) }}
|
||||
</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}`) }}
|
||||
</YListGroupItem>
|
||||
<YListGroupItem variant="info">
|
||||
{{ $t(`app.integration.multi_instance.${app.integration.multi_instance}`) }}
|
||||
{{
|
||||
$t(
|
||||
`app.integration.multi_instance.${app.integration.multi_instance}`,
|
||||
)
|
||||
}}
|
||||
</YListGroupItem>
|
||||
<YListGroupItem variant="info">
|
||||
{{ $t('app.integration.resources', app.integration.resources) }}
|
||||
|
@ -59,8 +76,12 @@
|
|||
</YCard>
|
||||
|
||||
<YCard
|
||||
id="app-links" icon="link" :title="$t('app.links.title')"
|
||||
collapsable collapsed no-body
|
||||
id="app-links"
|
||||
icon="link"
|
||||
:title="$t('app.links.title')"
|
||||
collapsable
|
||||
collapsed
|
||||
no-body
|
||||
>
|
||||
<template #header>
|
||||
<h2><YIcon iname="link" /> {{ $t('app.links.title') }}</h2>
|
||||
|
@ -94,14 +115,23 @@
|
|||
</dl>
|
||||
</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
|
||||
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>
|
||||
|
||||
|
@ -109,35 +139,58 @@
|
|||
{{ $t('app.install.problems.arch', app.requirements.arch.values) }}
|
||||
</p>
|
||||
<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 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>
|
||||
</YAlert>
|
||||
|
||||
<YAlert v-else-if="app.hasDanger" variant="danger" class="my-4">
|
||||
<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">
|
||||
{{ $t('app.install.problems.ram', app.requirements.ram.values) }}
|
||||
</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>
|
||||
|
||||
<!-- INSTALL FORM -->
|
||||
<CardForm
|
||||
v-if="app.canInstall || force"
|
||||
:title="$t('app_install_parameters')" icon="cog" :submit-text="$t('install')"
|
||||
:validation="$v" :server-error="serverError"
|
||||
:title="$t('app_install_parameters')"
|
||||
icon="cog"
|
||||
:submit-text="$t('install')"
|
||||
:validation="$v"
|
||||
:server-error="serverError"
|
||||
@submit.prevent="performInstall"
|
||||
>
|
||||
<template v-for="(field, fname) in fields">
|
||||
<Component
|
||||
v-if="field.visible" :is="field.is" v-bind="field.props"
|
||||
v-model="form[fname]" :validation="$v.form[fname]" :key="fname"
|
||||
v-if="field.visible"
|
||||
:is="field.is"
|
||||
v-bind="field.props"
|
||||
v-model="form[fname]"
|
||||
:validation="$v.form[fname]"
|
||||
:key="fname"
|
||||
/>
|
||||
</template>
|
||||
</CardForm>
|
||||
|
@ -145,7 +198,8 @@
|
|||
|
||||
<!-- In case of a custom url with no manifest found -->
|
||||
<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>
|
||||
|
||||
<template #skeleton>
|
||||
|
@ -162,7 +216,7 @@ import api, { objectToParams } from '@/api'
|
|||
import {
|
||||
formatYunoHostArguments,
|
||||
formatI18nField,
|
||||
formatFormData
|
||||
formatFormData,
|
||||
} from '@/helpers/yunohostArguments'
|
||||
import CardCollapse from '@/components/CardCollapse.vue'
|
||||
|
||||
|
@ -172,18 +226,18 @@ export default {
|
|||
mixins: [validationMixin],
|
||||
|
||||
components: {
|
||||
CardCollapse
|
||||
CardCollapse,
|
||||
},
|
||||
|
||||
props: {
|
||||
id: { type: String, required: true }
|
||||
id: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['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,
|
||||
name: undefined,
|
||||
|
@ -192,16 +246,16 @@ export default {
|
|||
validations: null,
|
||||
errors: undefined,
|
||||
serverError: '',
|
||||
force: false
|
||||
force: false,
|
||||
}
|
||||
},
|
||||
|
||||
validations () {
|
||||
validations() {
|
||||
return this.validations
|
||||
},
|
||||
|
||||
methods: {
|
||||
appLinksIcons (linkType) {
|
||||
appLinksIcons(linkType) {
|
||||
const linksIcons = {
|
||||
license: 'institution',
|
||||
website: 'globe',
|
||||
|
@ -210,16 +264,25 @@ export default {
|
|||
code: 'code',
|
||||
package: 'code',
|
||||
package_license: 'institution',
|
||||
forum: 'comments'
|
||||
forum: 'comments',
|
||||
}
|
||||
return linksIcons[linkType]
|
||||
},
|
||||
|
||||
onQueriesResponse (catalog, _app) {
|
||||
const antifeaturesList = Object.fromEntries(catalog.antifeatures.map((af) => ([af.id, af])))
|
||||
onQueriesResponse(catalog, _app) {
|
||||
const antifeaturesList = Object.fromEntries(
|
||||
catalog.antifeatures.map((af) => [af.id, af]),
|
||||
)
|
||||
|
||||
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' }
|
||||
if (quality.state === 'working') {
|
||||
|
@ -230,7 +293,8 @@ export default {
|
|||
quality.variant = 'warning'
|
||||
} else {
|
||||
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)
|
||||
|
@ -247,38 +311,47 @@ export default {
|
|||
const app = {
|
||||
id,
|
||||
name,
|
||||
alternativeTo: _app.potential_alternative_to && _app.potential_alternative_to.length
|
||||
? _app.potential_alternative_to.join(this.$i18n.t('words.separator'))
|
||||
: null,
|
||||
alternativeTo:
|
||||
_app.potential_alternative_to && _app.potential_alternative_to.length
|
||||
? _app.potential_alternative_to.join(
|
||||
this.$i18n.t('words.separator'),
|
||||
)
|
||||
: null,
|
||||
description: formatI18nField(_app.doc.DESCRIPTION || _app.description),
|
||||
screenshot: _app.screenshot,
|
||||
demo: _app.upstream.demo,
|
||||
version,
|
||||
license: _app.upstream.license,
|
||||
integration: _app.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,
|
||||
integration:
|
||||
_app.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: [
|
||||
['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_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),
|
||||
preInstall,
|
||||
antifeatures,
|
||||
quality,
|
||||
requirements,
|
||||
hasWarning: !!preInstall || antifeatures || quality.variant === 'warning',
|
||||
hasWarning:
|
||||
!!preInstall || antifeatures || quality.variant === 'warning',
|
||||
hasDanger,
|
||||
hasSupport,
|
||||
canInstall: hasSupport && !hasDanger
|
||||
canInstall: hasSupport && !hasDanger,
|
||||
}
|
||||
|
||||
// FIXME yunohost should add the label field by default
|
||||
|
@ -286,15 +359,12 @@ export default {
|
|||
ask: this.$t('label_for_manifestname', { name }),
|
||||
default: name,
|
||||
name: 'label',
|
||||
help: this.$t('label_for_manifestname_help')
|
||||
help: this.$t('label_for_manifestname_help'),
|
||||
})
|
||||
|
||||
const {
|
||||
form,
|
||||
fields,
|
||||
validations,
|
||||
errors
|
||||
} = formatYunoHostArguments(_app.install)
|
||||
const { form, fields, validations, errors } = formatYunoHostArguments(
|
||||
_app.install,
|
||||
)
|
||||
|
||||
this.app = app
|
||||
this.fields = fields
|
||||
|
@ -303,51 +373,70 @@ export default {
|
|||
this.errors = errors
|
||||
},
|
||||
|
||||
formatAppNotifs (notifs) {
|
||||
formatAppNotifs(notifs) {
|
||||
return Object.keys(notifs).reduce((acc, key) => {
|
||||
return acc + '\n\n' + notifs[key]
|
||||
}, '')
|
||||
},
|
||||
|
||||
async performInstall () {
|
||||
async performInstall() {
|
||||
if ('path' in this.form && this.form.path === '/') {
|
||||
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
|
||||
}
|
||||
|
||||
const { data: args, label } = await formatFormData(
|
||||
this.form,
|
||||
{ extract: ['label'], 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: args, label } = await formatFormData(this.form, {
|
||||
extract: ['label'],
|
||||
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
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.antifeatures {
|
||||
dt::before {
|
||||
content: "• ";
|
||||
content: '• ';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -16,8 +16,9 @@
|
|||
|
||||
<BListGroup>
|
||||
<BListGroupItem
|
||||
v-for="{ id, description, label } in filteredApps" :key="id"
|
||||
:to="{ name: 'app-info', params: { id }}"
|
||||
v-for="{ id, description, label } in filteredApps"
|
||||
:key="id"
|
||||
:to="{ name: 'app-info', params: { id } }"
|
||||
class="d-flex justify-content-between align-items-center pr-0"
|
||||
>
|
||||
<div>
|
||||
|
@ -40,40 +41,40 @@
|
|||
export default {
|
||||
name: 'AppList',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', 'apps?full']
|
||||
],
|
||||
queries: [['GET', 'apps?full']],
|
||||
search: '',
|
||||
apps: undefined
|
||||
apps: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredApps () {
|
||||
filteredApps() {
|
||||
if (!this.apps) return
|
||||
const search = this.search.toLowerCase()
|
||||
const match = (item) => item && item.toLowerCase().includes(search)
|
||||
// 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
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse ({ apps }) {
|
||||
onQueriesResponse({ apps }) {
|
||||
if (apps.length === 0) {
|
||||
this.apps = null
|
||||
return
|
||||
}
|
||||
|
||||
this.apps = apps.map(({ id, name, description, manifest }) => {
|
||||
return { id, name: manifest.name, label: name, description }
|
||||
}).sort((prev, app) => {
|
||||
return prev.label > app.label ? 1 : -1
|
||||
})
|
||||
}
|
||||
}
|
||||
this.apps = apps
|
||||
.map(({ id, name, description, manifest }) => {
|
||||
return { id, name: manifest.name, label: name, description }
|
||||
})
|
||||
.sort((prev, app) => {
|
||||
return prev.label > app.label ? 1 : -1
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,34 +1,46 @@
|
|||
<template>
|
||||
<ViewBase :queries="queries" @queries-response="onQueriesResponse" skeleton="CardListSkeleton">
|
||||
<ViewBase
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
skeleton="CardListSkeleton"
|
||||
>
|
||||
<!-- FIXME switch to <CardForm> ? -->
|
||||
<YCard :title="$t('backup_create')" icon="archive" no-body>
|
||||
<BFormCheckboxGroup
|
||||
v-model="selected"
|
||||
id="backup-select" name="backup-select" size="lg"
|
||||
id="backup-select"
|
||||
name="backup-select"
|
||||
size="lg"
|
||||
>
|
||||
<BListGroup flush>
|
||||
<!-- SYSTEM HEADER -->
|
||||
<BListGroupItem class="d-flex align-items-sm-center flex-column flex-sm-row text-primary">
|
||||
<h4 class="m-0">
|
||||
<YIcon iname="cube" /> {{ $t('system') }}
|
||||
</h4>
|
||||
<BListGroupItem
|
||||
class="d-flex align-items-sm-center flex-column flex-sm-row text-primary"
|
||||
>
|
||||
<h4 class="m-0"><YIcon iname="cube" /> {{ $t('system') }}</h4>
|
||||
|
||||
<div class="ml-sm-auto mt-2 mt-sm-0">
|
||||
<BButton
|
||||
@click="toggleSelected(true, 'system')" v-t="'select_all'"
|
||||
size="sm" variant="outline-dark"
|
||||
@click="toggleSelected(true, 'system')"
|
||||
v-t="'select_all'"
|
||||
size="sm"
|
||||
variant="outline-dark"
|
||||
/>
|
||||
|
||||
<BButton
|
||||
@click="toggleSelected(false, 'system')" v-t="'select_none'"
|
||||
size="sm" variant="outline-dark" class="ml-2"
|
||||
@click="toggleSelected(false, 'system')"
|
||||
v-t="'select_none'"
|
||||
size="sm"
|
||||
variant="outline-dark"
|
||||
class="ml-2"
|
||||
/>
|
||||
</div>
|
||||
</BListGroupItem>
|
||||
|
||||
<!-- SYSTEM ITEMS -->
|
||||
<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"
|
||||
>
|
||||
<div class="mr-2">
|
||||
|
@ -40,43 +52,60 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<BFormCheckbox :value="partName" :aria-label="$t('check')" class="d-inline" />
|
||||
<BFormCheckbox
|
||||
:value="partName"
|
||||
:aria-label="$t('check')"
|
||||
class="d-inline"
|
||||
/>
|
||||
</BListGroupItem>
|
||||
|
||||
<!-- 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">
|
||||
<YIcon iname="cubes" /> {{ $t('applications') }}
|
||||
</h4>
|
||||
|
||||
<div class="ml-sm-auto mt-2 mt-sm-0">
|
||||
<BButton
|
||||
@click="toggleSelected(true, 'apps')" v-t="'select_all'"
|
||||
size="sm" variant="outline-dark"
|
||||
@click="toggleSelected(true, 'apps')"
|
||||
v-t="'select_all'"
|
||||
size="sm"
|
||||
variant="outline-dark"
|
||||
/>
|
||||
|
||||
<BButton
|
||||
@click="toggleSelected(false, 'apps')" v-t="'select_none'"
|
||||
size="sm" variant="outline-dark" class="ml-2"
|
||||
@click="toggleSelected(false, 'apps')"
|
||||
v-t="'select_none'"
|
||||
size="sm"
|
||||
variant="outline-dark"
|
||||
class="ml-2"
|
||||
/>
|
||||
</div>
|
||||
</BListGroupItem>
|
||||
|
||||
<!-- APPS ITEMS -->
|
||||
<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"
|
||||
>
|
||||
<div class="mr-2">
|
||||
<h5 class="font-weight-bold">
|
||||
{{ item.name }} <small class="text-secondary">{{ item.id }}</small>
|
||||
{{ item.name }}
|
||||
<small class="text-secondary">{{ item.id }}</small>
|
||||
</h5>
|
||||
<p class="m-0">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BFormCheckbox :value="appName" :aria-label="$t('check')" class="d-inline" />
|
||||
<BFormCheckbox
|
||||
:value="appName"
|
||||
:aria-label="$t('check')"
|
||||
class="d-inline"
|
||||
/>
|
||||
</BListGroupItem>
|
||||
</BListGroup>
|
||||
</BFormCheckboxGroup>
|
||||
|
@ -84,8 +113,10 @@
|
|||
<!-- SUBMIT -->
|
||||
<template #buttons>
|
||||
<BButton
|
||||
@click="createBackup" v-t="'backup_action'"
|
||||
variant="success" :disabled="selected.length === 0"
|
||||
@click="createBackup"
|
||||
v-t="'backup_action'"
|
||||
variant="success"
|
||||
:disabled="selected.length === 0"
|
||||
/>
|
||||
</template>
|
||||
</YCard>
|
||||
|
@ -99,27 +130,29 @@ export default {
|
|||
name: 'BackupCreate',
|
||||
|
||||
props: {
|
||||
id: { type: String, required: true }
|
||||
id: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', 'hooks/backup'],
|
||||
['GET', 'apps?with_backup']
|
||||
['GET', 'apps?with_backup'],
|
||||
],
|
||||
selected: [],
|
||||
// api data
|
||||
system: undefined,
|
||||
apps: undefined
|
||||
apps: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatHooks (hooks) {
|
||||
formatHooks(hooks) {
|
||||
const data = {}
|
||||
hooks.forEach(hook => {
|
||||
const groupId = hook.startsWith('conf_') ? 'adminjs_group_configuration' : hook
|
||||
hooks.forEach((hook) => {
|
||||
const groupId = hook.startsWith('conf_')
|
||||
? 'adminjs_group_configuration'
|
||||
: hook
|
||||
if (groupId in data) {
|
||||
data[groupId].value.push(hook)
|
||||
data[groupId].description += ', ' + this.$i18n.t('hook_' + hook)
|
||||
|
@ -127,14 +160,16 @@ export default {
|
|||
data[groupId] = {
|
||||
name: this.$i18n.t('hook_' + groupId),
|
||||
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
|
||||
},
|
||||
|
||||
onQueriesResponse ({ hooks }, { apps }) {
|
||||
onQueriesResponse({ hooks }, { apps }) {
|
||||
this.system = this.formatHooks(hooks)
|
||||
// transform app array into literal object to match hooks data structure
|
||||
this.apps = apps.reduce((obj, app) => {
|
||||
|
@ -144,17 +179,21 @@ export default {
|
|||
this.selected = [...Object.keys(this.system), ...Object.keys(this.apps)]
|
||||
},
|
||||
|
||||
toggleSelected (select, type) {
|
||||
toggleSelected(select, type) {
|
||||
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]
|
||||
} else {
|
||||
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: [] }
|
||||
for (const item of this.selected) {
|
||||
if (item in this.system) {
|
||||
|
@ -167,7 +206,7 @@ export default {
|
|||
api.post('backups', data, 'backups.create').then(() => {
|
||||
this.$router.push({ name: 'backup-list', params: { id: this.id } })
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -15,8 +15,10 @@
|
|||
</template>
|
||||
|
||||
<BRow
|
||||
v-for="(value, prop) in infos" :key="prop"
|
||||
no-gutters class="row-line"
|
||||
v-for="(value, prop) in infos"
|
||||
:key="prop"
|
||||
no-gutters
|
||||
class="row-line"
|
||||
>
|
||||
<BCol md="3" xl="2">
|
||||
<strong>{{ $t(prop === 'name' ? 'id' : prop) }}</strong>
|
||||
|
@ -32,35 +34,48 @@
|
|||
<!-- BACKUP CONTENT -->
|
||||
<!-- FIXME switch to <CardForm> ? -->
|
||||
<YCard
|
||||
:title="$t('backup_content')" icon="archive"
|
||||
no-body button-unbreak="sm"
|
||||
:title="$t('backup_content')"
|
||||
icon="archive"
|
||||
no-body
|
||||
button-unbreak="sm"
|
||||
>
|
||||
<template #header-buttons>
|
||||
<BButton
|
||||
size="sm" variant="outline-secondary"
|
||||
@click="toggleSelected()" v-t="'select_all'"
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
@click="toggleSelected()"
|
||||
v-t="'select_all'"
|
||||
/>
|
||||
|
||||
<BButton
|
||||
size="sm" variant="outline-secondary"
|
||||
@click="toggleSelected(false)" v-t="'select_none'"
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
@click="toggleSelected(false)"
|
||||
v-t="'select_none'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<BFormCheckboxGroup
|
||||
v-if="hasBackupData" v-model="selected"
|
||||
id="backup-select" name="backup-select" size="lg"
|
||||
v-if="hasBackupData"
|
||||
v-model="selected"
|
||||
id="backup-select"
|
||||
name="backup-select"
|
||||
size="lg"
|
||||
aria-describedby="backup-restore-feedback"
|
||||
>
|
||||
<BListGroup flush>
|
||||
<!-- SYSTEM PARTS -->
|
||||
<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"
|
||||
>
|
||||
<div class="mr-2">
|
||||
<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>
|
||||
<p class="m-0">
|
||||
{{ item.description }}
|
||||
|
@ -72,16 +87,18 @@
|
|||
|
||||
<!-- APPS -->
|
||||
<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"
|
||||
>
|
||||
<div class="mr-2">
|
||||
<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>
|
||||
<p class="m-0">
|
||||
{{ $t('version') }} {{ item.version }}
|
||||
</p>
|
||||
<p class="m-0">{{ $t('version') }} {{ item.version }}</p>
|
||||
</div>
|
||||
|
||||
<BFormCheckbox :value="appName" :aria-label="$t('check')" />
|
||||
|
@ -102,8 +119,11 @@
|
|||
<!-- SUBMIT -->
|
||||
<template v-if="hasBackupData" #buttons>
|
||||
<BButton
|
||||
@click="restoreBackup" form="backup-restore" variant="success"
|
||||
v-t="'restore'" :disabled="selected.length === 0"
|
||||
@click="restoreBackup"
|
||||
form="backup-restore"
|
||||
variant="success"
|
||||
v-t="'restore'"
|
||||
:disabled="selected.length === 0"
|
||||
/>
|
||||
</template>
|
||||
</YCard>
|
||||
|
@ -126,35 +146,35 @@ export default {
|
|||
|
||||
props: {
|
||||
id: { type: String, required: true },
|
||||
name: { type: String, required: true }
|
||||
name: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', `backups/${this.name}?with_details`]
|
||||
],
|
||||
queries: [['GET', `backups/${this.name}?with_details`]],
|
||||
selected: [],
|
||||
error: '',
|
||||
isValid: null,
|
||||
// api data
|
||||
infos: undefined,
|
||||
apps: undefined,
|
||||
system: undefined
|
||||
system: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasBackupData () {
|
||||
hasBackupData() {
|
||||
return !isEmptyValue(this.system) || !isEmptyValue(this.apps)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatHooks (hooks) {
|
||||
formatHooks(hooks) {
|
||||
const data = {}
|
||||
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) {
|
||||
data[groupId].value.push(hook)
|
||||
data[groupId].description += ', ' + this.$i18n.t('hook_' + hook)
|
||||
|
@ -163,20 +183,22 @@ export default {
|
|||
data[groupId] = {
|
||||
name: this.$i18n.t('hook_' + groupId),
|
||||
value: [hook],
|
||||
description: this.$i18n.t(groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook),
|
||||
size
|
||||
description: this.$i18n.t(
|
||||
groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook,
|
||||
),
|
||||
size,
|
||||
}
|
||||
}
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
onQueriesResponse (data) {
|
||||
onQueriesResponse(data) {
|
||||
this.infos = {
|
||||
name: this.name,
|
||||
created_at: data.created_at,
|
||||
size: data.size,
|
||||
path: data.path
|
||||
path: data.path,
|
||||
}
|
||||
this.system = this.formatHooks(data.system)
|
||||
this.apps = data.apps
|
||||
|
@ -184,20 +206,17 @@ export default {
|
|||
this.toggleSelected()
|
||||
},
|
||||
|
||||
toggleSelected (select = true) {
|
||||
toggleSelected(select = true) {
|
||||
if (select) {
|
||||
this.selected = [
|
||||
...Object.keys(this.apps),
|
||||
...Object.keys(this.system)
|
||||
]
|
||||
this.selected = [...Object.keys(this.apps), ...Object.keys(this.system)]
|
||||
} else {
|
||||
this.selected = []
|
||||
}
|
||||
},
|
||||
|
||||
async restoreBackup () {
|
||||
async restoreBackup() {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_restore', { name: this.name })
|
||||
this.$i18n.t('confirm_restore', { name: this.name }),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
|
@ -210,35 +229,48 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
api.put(
|
||||
`backups/${this.name}/restore`, data, { key: 'backups.restore', name: this.name }
|
||||
).then(() => {
|
||||
this.isValid = null
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
this.error = err.message
|
||||
this.isValid = false
|
||||
})
|
||||
api
|
||||
.put(`backups/${this.name}/restore`, data, {
|
||||
key: 'backups.restore',
|
||||
name: this.name,
|
||||
})
|
||||
.then(() => {
|
||||
this.isValid = null
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
this.error = err.message
|
||||
this.isValid = false
|
||||
})
|
||||
},
|
||||
|
||||
async deleteBackup () {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
|
||||
async deleteBackup() {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_delete', { name: this.name }),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
api.delete(
|
||||
'backups/' + this.name, {}, { key: 'backups.delete', name: this.name }
|
||||
).then(() => {
|
||||
this.$router.push({ name: 'backup-list', params: { id: this.id } })
|
||||
})
|
||||
api
|
||||
.delete(
|
||||
'backups/' + this.name,
|
||||
{},
|
||||
{ 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
|
||||
window.open(`https://${host}/yunohost/api/backups/${this.name}/download`, '_blank')
|
||||
window.open(
|
||||
`https://${host}/yunohost/api/backups/${this.name}/download`,
|
||||
'_blank',
|
||||
)
|
||||
},
|
||||
|
||||
readableDate,
|
||||
humanSize
|
||||
}
|
||||
humanSize,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
<template>
|
||||
<ViewBase :queries="queries" @queries-response="onQueriesResponse" skeleton="ListGroupSkeleton">
|
||||
<ViewBase
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
skeleton="ListGroupSkeleton"
|
||||
>
|
||||
<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>
|
||||
|
||||
<BAlert v-if="!archives" variant="warning">
|
||||
|
@ -11,15 +21,18 @@
|
|||
|
||||
<BListGroup v-else>
|
||||
<BListGroupItem
|
||||
v-for="{ name, created_at, path, size } in archives" :key="name"
|
||||
:to="{ name: 'backup-info', params: { name, id }}"
|
||||
v-for="{ name, created_at, path, size } in archives"
|
||||
:key="name"
|
||||
:to="{ name: 'backup-info', params: { name, id } }"
|
||||
:title="readableDate(created_at)"
|
||||
class="d-flex justify-content-between align-items-center pr-0"
|
||||
>
|
||||
<div>
|
||||
<h5 class="font-weight-bold">
|
||||
{{ distanceToNow(created_at) }}
|
||||
<small class="text-secondary">{{ name }} ({{ humanSize(size) }})</small>
|
||||
<small class="text-secondary"
|
||||
>{{ name }} ({{ humanSize(size) }})</small
|
||||
>
|
||||
</h5>
|
||||
<p class="mb-0">
|
||||
{{ path }}
|
||||
|
@ -39,26 +52,26 @@ export default {
|
|||
name: 'BackupList',
|
||||
|
||||
props: {
|
||||
id: { type: String, required: true }
|
||||
id: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', 'backups?with_info']
|
||||
],
|
||||
archives: undefined
|
||||
queries: [['GET', 'backups?with_info']],
|
||||
archives: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (data) {
|
||||
onQueriesResponse(data) {
|
||||
const archives = Object.entries(data.archives)
|
||||
if (archives.length) {
|
||||
this.archives = archives.map(([name, infos]) => {
|
||||
infos.name = name
|
||||
return infos
|
||||
}).reverse()
|
||||
this.archives = archives
|
||||
.map(([name, infos]) => {
|
||||
infos.name = name
|
||||
return infos
|
||||
})
|
||||
.reverse()
|
||||
} else {
|
||||
this.archives = null
|
||||
}
|
||||
|
@ -66,7 +79,7 @@ export default {
|
|||
|
||||
distanceToNow,
|
||||
readableDate,
|
||||
humanSize
|
||||
}
|
||||
humanSize,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -2,8 +2,9 @@
|
|||
<div>
|
||||
<BListGroup>
|
||||
<BListGroupItem
|
||||
v-for="{ id, name, uri } in storages" :key="id"
|
||||
:to="{ name: 'backup-list', params: { id }}"
|
||||
v-for="{ id, name, uri } in storages"
|
||||
:key="id"
|
||||
:to="{ name: 'backup-list', params: { id } }"
|
||||
class="d-flex justify-content-between align-items-center pr-0"
|
||||
>
|
||||
<div>
|
||||
|
@ -25,16 +26,16 @@
|
|||
export default {
|
||||
name: 'BackupView',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
storages: [
|
||||
{
|
||||
id: 'local',
|
||||
name: this.$i18n.t('local_archives'),
|
||||
uri: '/home/yunohost.backup/'
|
||||
}
|
||||
]
|
||||
uri: '/home/yunohost.backup/',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<ViewBase
|
||||
:queries="queries" @queries-response="onQueriesResponse" queries-wait
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
queries-wait
|
||||
ref="view"
|
||||
>
|
||||
<template #top-bar-group-right>
|
||||
|
@ -13,7 +15,9 @@
|
|||
<div class="alert alert-info">
|
||||
{{ $t(reports ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
|
||||
<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()"
|
||||
>
|
||||
<YIcon iname="stethoscope" /> {{ $t('run_first_diagnosis') }}
|
||||
|
@ -23,24 +27,46 @@
|
|||
|
||||
<!-- REPORT CARD -->
|
||||
<YCard
|
||||
v-for="report in reports" :key="report.id"
|
||||
collapsable :collapsed="report.noIssues"
|
||||
no-body button-unbreak="lg"
|
||||
v-for="report in reports"
|
||||
:key="report.id"
|
||||
collapsable
|
||||
:collapsed="report.noIssues"
|
||||
no-body
|
||||
button-unbreak="lg"
|
||||
>
|
||||
<!-- REPORT HEADER -->
|
||||
<template #header>
|
||||
<h2>{{ report.description }}</h2>
|
||||
|
||||
<div class="">
|
||||
<BBadge v-if="report.noIssues" variant="success" 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 } }" />
|
||||
<BBadge
|
||||
v-if="report.noIssues"
|
||||
variant="success"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<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') }}
|
||||
</BButton>
|
||||
</template>
|
||||
|
@ -53,21 +79,27 @@
|
|||
<BListGroup flush>
|
||||
<!-- REPORT ITEM -->
|
||||
<YListGroupItem
|
||||
v-for="(item, i) in report.items" :key="i"
|
||||
:variant="item.variant" :icon="item.Icon" :faded="item.ignored"
|
||||
v-for="(item, i) in report.items"
|
||||
:key="i"
|
||||
:variant="item.variant"
|
||||
:icon="item.Icon"
|
||||
:faded="item.ignored"
|
||||
>
|
||||
<div class="item-button d-flex align-items-center">
|
||||
<p class="mb-0 mr-2" v-html="item.summary" />
|
||||
|
||||
<div class="d-flex flex-column flex-lg-row ml-auto">
|
||||
<BButton
|
||||
v-if="item.ignored" size="sm"
|
||||
v-if="item.ignored"
|
||||
size="sm"
|
||||
@click="toggleIgnoreIssue('unignore', report, item)"
|
||||
>
|
||||
<YIcon iname="bell" /> {{ $t('unignore') }}
|
||||
</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)"
|
||||
>
|
||||
<YIcon iname="bell-slash" /> {{ $t('ignore') }}
|
||||
|
@ -75,7 +107,9 @@
|
|||
|
||||
<BButton
|
||||
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}`"
|
||||
>
|
||||
<YIcon iname="level-down" /> {{ $t('details') }}
|
||||
|
@ -83,9 +117,16 @@
|
|||
</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">
|
||||
<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>
|
||||
</BCollapse>
|
||||
</YListGroupItem>
|
||||
|
@ -114,22 +155,22 @@ import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
|
|||
export default {
|
||||
name: 'DiagnosisView',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['PUT', 'diagnosis/run?except_if_never_ran_yet', {}, 'diagnosis.run'],
|
||||
['GET', 'diagnosis?full']
|
||||
['GET', 'diagnosis?full'],
|
||||
],
|
||||
reports: undefined
|
||||
reports: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['theme'])
|
||||
...mapGetters(['theme']),
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (_, reportsData) {
|
||||
onQueriesResponse(_, reportsData) {
|
||||
if (reportsData === null) {
|
||||
this.reports = null
|
||||
return
|
||||
|
@ -142,7 +183,7 @@ export default {
|
|||
report.ignoreds = 0
|
||||
|
||||
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.issue = false
|
||||
|
||||
|
@ -164,53 +205,58 @@ export default {
|
|||
this.reports = reports
|
||||
},
|
||||
|
||||
runDiagnosis ({ id = null, description } = {}) {
|
||||
runDiagnosis({ id = null, description } = {}) {
|
||||
const param = id !== null ? '?force' : ''
|
||||
const data = id !== null ? { categories: [id] } : {}
|
||||
|
||||
api.put(
|
||||
'diagnosis/run' + param,
|
||||
data,
|
||||
{ key: 'diagnosis.run' + (id !== null ? '_specific' : ''), description }
|
||||
).then(this.$refs.view.fetchQueries)
|
||||
api
|
||||
.put('diagnosis/run' + param, data, {
|
||||
key: 'diagnosis.run' + (id !== null ? '_specific' : ''),
|
||||
description,
|
||||
})
|
||||
.then(this.$refs.view.fetchQueries)
|
||||
},
|
||||
|
||||
toggleIgnoreIssue (action, report, item) {
|
||||
const filterArgs = [report.id].concat(Object.entries(item.meta).map(entries => entries.join('=')))
|
||||
toggleIgnoreIssue(action, report, item) {
|
||||
const filterArgs = [report.id].concat(
|
||||
Object.entries(item.meta).map((entries) => entries.join('=')),
|
||||
)
|
||||
|
||||
api.put(
|
||||
'diagnosis/' + action,
|
||||
{ filter: filterArgs },
|
||||
`diagnosis.${action}.${item.status.toLowerCase()}`
|
||||
).then(() => {
|
||||
item.ignored = action === 'ignore'
|
||||
if (item.ignored) {
|
||||
report[item.status.toLowerCase() + 's']--
|
||||
} else {
|
||||
report.ignoreds--
|
||||
}
|
||||
this.formatReportItem(report, item)
|
||||
})
|
||||
api
|
||||
.put(
|
||||
'diagnosis/' + action,
|
||||
{ filter: filterArgs },
|
||||
`diagnosis.${action}.${item.status.toLowerCase()}`,
|
||||
)
|
||||
.then(() => {
|
||||
item.ignored = action === 'ignore'
|
||||
if (item.ignored) {
|
||||
report[item.status.toLowerCase() + 's']--
|
||||
} else {
|
||||
report.ignoreds--
|
||||
}
|
||||
this.formatReportItem(report, item)
|
||||
})
|
||||
},
|
||||
|
||||
shareLogs () {
|
||||
shareLogs() {
|
||||
api.get('diagnosis?share').then(({ url }) => {
|
||||
window.open(url, '_blank')
|
||||
})
|
||||
},
|
||||
|
||||
distanceToNow
|
||||
}
|
||||
distanceToNow,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.badge + .badge {
|
||||
margin-left: .5rem
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
p.last-time-run {
|
||||
margin: .75rem 1rem;
|
||||
margin: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<template>
|
||||
<ViewBase :queries="queries" skeleton="CardFormSkeleton">
|
||||
<DomainForm
|
||||
:title="$t('domain_add')" :server-error="serverError"
|
||||
@submit="onSubmit" :submit-text="$t('add')"
|
||||
:title="$t('domain_add')"
|
||||
:server-error="serverError"
|
||||
@submit="onSubmit"
|
||||
:submit-text="$t('add')"
|
||||
/>
|
||||
</ViewBase>
|
||||
</template>
|
||||
|
@ -14,29 +16,28 @@ import { DomainForm } from '@/views/_partials'
|
|||
export default {
|
||||
name: 'DomainAdd',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', { uri: 'domains' }]
|
||||
],
|
||||
serverError: ''
|
||||
queries: [['GET', { uri: 'domains' }]],
|
||||
serverError: '',
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit (data) {
|
||||
api.post(
|
||||
'domains', data, { key: 'domains.add', name: data.domain }
|
||||
).then(() => {
|
||||
this.$store.dispatch('RESET_CACHE_DATA', ['domains'])
|
||||
this.$router.push({ name: 'domain-list' })
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
this.serverError = err.message
|
||||
})
|
||||
}
|
||||
onSubmit(data) {
|
||||
api
|
||||
.post('domains', data, { key: 'domains.add', name: data.domain })
|
||||
.then(() => {
|
||||
this.$store.dispatch('RESET_CACHE_DATA', ['domains'])
|
||||
this.$router.push({ name: 'domain-list' })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
this.serverError = err.message
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
components: { DomainForm }
|
||||
components: { DomainForm },
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<ViewBase
|
||||
:queries="queries" @queries-response="onQueriesResponse" :loading="loading"
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
:loading="loading"
|
||||
skeleton="CardInfoSkeleton"
|
||||
>
|
||||
<section v-if="showAutoConfigCard" class="panel-section">
|
||||
|
@ -16,22 +18,49 @@
|
|||
|
||||
<!-- AUTO CONFIG CHANGES -->
|
||||
<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">
|
||||
{{ action }}
|
||||
</h4>
|
||||
|
||||
<div class="log">
|
||||
<div
|
||||
v-for="({ 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"
|
||||
v-for="(
|
||||
{
|
||||
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" />
|
||||
{{ record }}
|
||||
<span class="bg-dark text-light px-1 rounded">{{ type }}</span>{{ spaces }}
|
||||
<span v-if="old_content"><span class="text-danger">{{ old_content }}</span> --> </span>
|
||||
<span :class="{ 'text-success': old_content }">{{ content }}</span>
|
||||
<span class="bg-dark text-light px-1 rounded">{{ type }}</span
|
||||
>{{ spaces }}
|
||||
<span v-if="old_content"
|
||||
><span class="text-danger">{{ old_content }}</span> -->
|
||||
</span>
|
||||
<span :class="{ 'text-success': old_content }">{{
|
||||
content
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -48,7 +77,8 @@
|
|||
<!-- CONFIG ERROR ALERT -->
|
||||
<template v-if="dnsErrors && dnsErrors.length">
|
||||
<ReadOnlyAlertItem
|
||||
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i"
|
||||
v-for="({ variant, icon, message }, i) in dnsErrors"
|
||||
:key="i"
|
||||
:label="message"
|
||||
:type="variant"
|
||||
:icon="icon"
|
||||
|
@ -75,15 +105,23 @@
|
|||
</section>
|
||||
|
||||
<!-- 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">
|
||||
{{ $t('domain.dns.auto_config_zone') }}
|
||||
</BCardTitle>
|
||||
|
||||
<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 }}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -113,14 +151,12 @@ export default {
|
|||
name: 'DomainDns',
|
||||
|
||||
props: {
|
||||
name: { type: String, required: true }
|
||||
name: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', `domains/${this.name}/dns/suggest`]
|
||||
],
|
||||
queries: [['GET', `domains/${this.name}/dns/suggest`]],
|
||||
loading: true,
|
||||
showAutoConfigCard: true,
|
||||
showManualConfigCard: false,
|
||||
|
@ -128,108 +164,121 @@ export default {
|
|||
dnsChanges: undefined,
|
||||
dnsErrors: undefined,
|
||||
dnsZone: undefined,
|
||||
force: null
|
||||
force: null,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (suggestedConfig) {
|
||||
onQueriesResponse(suggestedConfig) {
|
||||
this.dnsConfig = suggestedConfig
|
||||
},
|
||||
|
||||
getDnsChanges () {
|
||||
getDnsChanges() {
|
||||
this.loading = true
|
||||
|
||||
return api.post(
|
||||
`domains/${this.name}/dns/push?dry_run`, {}, null, { wait: false, websocket: false }
|
||||
).then(dnsChanges => {
|
||||
function getLongest (arr, key) {
|
||||
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 })
|
||||
}
|
||||
return api
|
||||
.post(`domains/${this.name}/dns/push?dry_run`, {}, null, {
|
||||
wait: false,
|
||||
websocket: false,
|
||||
})
|
||||
.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
|
||||
if (unchanged) {
|
||||
const longestName = getLongest(unchanged, 'name')
|
||||
const longestType = getLongest(unchanged, 'type')
|
||||
unchanged.forEach(record => {
|
||||
record.name = record.name + ' '.repeat(longestName - record.name.length + 1)
|
||||
record.spaces = ' '.repeat(longestType - record.type.length + 1)
|
||||
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 })
|
||||
}
|
||||
})
|
||||
this.dnsZone = unchanged
|
||||
}
|
||||
|
||||
this.dnsChanges = changes.length > 0 ? changes : null
|
||||
this.force = canForce ? false : null
|
||||
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
|
||||
})
|
||||
const unchanged = dnsChanges.unchanged
|
||||
if (unchanged) {
|
||||
const longestName = getLongest(unchanged, 'name')
|
||||
const longestType = getLongest(unchanged, 'type')
|
||||
unchanged.forEach((record) => {
|
||||
record.name =
|
||||
record.name + ' '.repeat(longestName - record.name.length + 1)
|
||||
record.spaces = ' '.repeat(longestType - record.type.length + 1)
|
||||
})
|
||||
this.dnsZone = unchanged
|
||||
}
|
||||
|
||||
this.dnsChanges = changes.length > 0 ? changes : null
|
||||
this.force = canForce ? false : null
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
api.post(
|
||||
`domains/${this.name}/dns/push${this.force ? '?force' : ''}`,
|
||||
{},
|
||||
{ key: 'domains.push_dns_changes', name: this.name }
|
||||
).then(async responseData => {
|
||||
await this.getDnsChanges()
|
||||
if (!isEmptyValue(responseData)) {
|
||||
this.dnsErrors = Object.keys(responseData).reduce((acc, key) => {
|
||||
const args = key === 'warnings'
|
||||
? { icon: 'warning', variant: 'warning' }
|
||||
: { icon: 'ban', variant: 'danger' }
|
||||
responseData[key].forEach(message => acc.push({ ...args, message }))
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
api
|
||||
.post(
|
||||
`domains/${this.name}/dns/push${this.force ? '?force' : ''}`,
|
||||
{},
|
||||
{ key: 'domains.push_dns_changes', name: this.name },
|
||||
)
|
||||
.then(async (responseData) => {
|
||||
await this.getDnsChanges()
|
||||
if (!isEmptyValue(responseData)) {
|
||||
this.dnsErrors = Object.keys(responseData).reduce((acc, key) => {
|
||||
const args =
|
||||
key === 'warnings'
|
||||
? { icon: 'warning', variant: 'warning' }
|
||||
: { icon: 'ban', variant: 'danger' }
|
||||
responseData[key].forEach((message) =>
|
||||
acc.push({ ...args, message }),
|
||||
)
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
created() {
|
||||
this.getDnsChanges()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<template>
|
||||
<ViewBase
|
||||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
ref="view" skeleton="CardListSkeleton"
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
ref="view"
|
||||
skeleton="CardListSkeleton"
|
||||
>
|
||||
<!-- INFO CARD -->
|
||||
<YCard v-if="domain" :title="name" icon="globe">
|
||||
|
@ -19,12 +21,20 @@
|
|||
|
||||
<template #header-buttons>
|
||||
<!-- DEFAULT DOMAIN -->
|
||||
<BButton v-if="!isMainDomain" @click="setAsDefaultDomain" variant="info">
|
||||
<BButton
|
||||
v-if="!isMainDomain"
|
||||
@click="setAsDefaultDomain"
|
||||
variant="info"
|
||||
>
|
||||
<YIcon iname="star" /> {{ $t('set_default') }}
|
||||
</BButton>
|
||||
|
||||
<!-- 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') }}
|
||||
</BButton>
|
||||
</template>
|
||||
|
@ -40,13 +50,24 @@
|
|||
<DescriptionRow :term="$t('domain.info.certificate_authority')">
|
||||
<YIcon :iname="cert.icon" :variant="cert.variant" class="mr-1" />
|
||||
{{ $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>
|
||||
|
||||
<!-- 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'">
|
||||
{{ $t('domain.see_parent_domain') }} <BLink :href="`#/domains/${domain.topest_parent}/dns`">
|
||||
{{ $t('domain.see_parent_domain') }} <BLink
|
||||
:href="`#/domains/${domain.topest_parent}/dns`"
|
||||
>
|
||||
{{ domain.topest_parent }}
|
||||
</BLink>
|
||||
</template>
|
||||
|
@ -59,15 +80,23 @@
|
|||
<DescriptionRow :term="$t('domain.info.apps_on_domain')">
|
||||
<div>
|
||||
<BButton-group
|
||||
v-for="app in domain.apps" :key="app.id"
|
||||
size="sm" class="mr-2 mb-2"
|
||||
v-for="app in domain.apps"
|
||||
: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 }}
|
||||
</BButton>
|
||||
<BButton
|
||||
variant="outline-dark" class="py-0 px-1"
|
||||
:href="'https://' + name + app.path" target="_blank"
|
||||
variant="outline-dark"
|
||||
class="py-0 px-1"
|
||||
:href="'https://' + name + app.path"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="sr-only">{{ $t('app.visit_app') }}</span>
|
||||
<YIcon iname="external-link" />
|
||||
|
@ -87,8 +116,12 @@
|
|||
|
||||
<BModal
|
||||
v-if="domain"
|
||||
id="delete-modal" :title="$t('confirm_delete', { name: this.name })" @ok="deleteDomain"
|
||||
header-bg-variant="warning" body-class="" body-bg-variant=""
|
||||
id="delete-modal"
|
||||
:title="$t('confirm_delete', { name: this.name })"
|
||||
@ok="deleteDomain"
|
||||
header-bg-variant="warning"
|
||||
body-class=""
|
||||
body-bg-variant=""
|
||||
>
|
||||
<BFormGroup v-if="isMainDynDomain">
|
||||
<BFormCheckbox v-model="unsubscribeDomainFromDyndns">
|
||||
|
@ -105,52 +138,54 @@ import { mapGetters } from 'vuex'
|
|||
import api, { objectToParams } from '@/api'
|
||||
import {
|
||||
formatFormData,
|
||||
formatYunoHostConfigPanels
|
||||
formatYunoHostConfigPanels,
|
||||
} from '@/helpers/yunohostArguments'
|
||||
import ConfigPanels from '@/components/ConfigPanels.vue'
|
||||
import DomainDns from './DomainDns.vue'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'DomainInfo',
|
||||
|
||||
components: {
|
||||
ConfigPanels,
|
||||
DomainDns
|
||||
DomainDns,
|
||||
},
|
||||
|
||||
props: {
|
||||
name: { type: String, required: true }
|
||||
name: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['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: {},
|
||||
unsubscribeDomainFromDyndns: false
|
||||
unsubscribeDomainFromDyndns: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['mainDomain']),
|
||||
|
||||
currentTab () {
|
||||
currentTab() {
|
||||
return this.$route.params.tabId
|
||||
},
|
||||
|
||||
domain () {
|
||||
domain() {
|
||||
return this.$store.getters.domain(this.name)
|
||||
},
|
||||
|
||||
parentName () {
|
||||
parentName() {
|
||||
return this.$store.getters.highestDomainParentName(this.name)
|
||||
},
|
||||
|
||||
cert () {
|
||||
cert() {
|
||||
const { CA_type: authority, validity } = this.domain.certificate
|
||||
const baseInfos = { authority, validity }
|
||||
if (validity <= 0) {
|
||||
|
@ -165,77 +200,98 @@ export default {
|
|||
return { icon: 'exclamation', variant: 'warning', ...baseInfos }
|
||||
},
|
||||
|
||||
dns () {
|
||||
dns() {
|
||||
return this.domain.dns
|
||||
},
|
||||
|
||||
isMainDomain () {
|
||||
isMainDomain() {
|
||||
if (!this.mainDomain) return
|
||||
return this.name === this.mainDomain
|
||||
},
|
||||
|
||||
isMainDynDomain () {
|
||||
return this.domain.registrar === 'yunohost' && this.name.split('.').length === 3
|
||||
}
|
||||
isMainDynDomain() {
|
||||
return (
|
||||
this.domain.registrar === 'yunohost' &&
|
||||
this.name.split('.').length === 3
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (domains, domain, config) {
|
||||
onQueriesResponse(domains, domain, config) {
|
||||
this.config = formatYunoHostConfigPanels(config)
|
||||
},
|
||||
|
||||
async onConfigSubmit ({ id, form, action, name }) {
|
||||
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||
|
||||
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 onConfigSubmit({ id, form, action, name }) {
|
||||
const args = await formatFormData(form, {
|
||||
removeEmpty: false,
|
||||
removeNull: true,
|
||||
})
|
||||
|
||||
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 () {
|
||||
const data = this.isMainDynDomain && !this.unsubscribeDomainFromDyndns
|
||||
? { ignore_dyndns: 1 }
|
||||
: {}
|
||||
async deleteDomain() {
|
||||
const data =
|
||||
this.isMainDynDomain && !this.unsubscribeDomainFromDyndns
|
||||
? { ignore_dyndns: 1 }
|
||||
: {}
|
||||
|
||||
api.delete(
|
||||
{ uri: 'domains', param: this.name }, data, { key: 'domains.delete', name: this.name }
|
||||
).then(() => {
|
||||
this.$router.push({ name: 'domain-list' })
|
||||
})
|
||||
api
|
||||
.delete({ uri: 'domains', param: this.name }, data, {
|
||||
key: 'domains.delete',
|
||||
name: this.name,
|
||||
})
|
||||
.then(() => {
|
||||
this.$router.push({ name: 'domain-list' })
|
||||
})
|
||||
},
|
||||
|
||||
async setAsDefaultDomain () {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_change_maindomain'))
|
||||
async setAsDefaultDomain() {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_change_maindomain'),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
api.put(
|
||||
{ uri: `domains/${this.name}/main`, storeKey: 'main_domain' },
|
||||
{},
|
||||
{ key: 'domains.set_default', name: 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
api
|
||||
.put(
|
||||
{ uri: `domains/${this.name}/main`, storeKey: 'main_domain' },
|
||||
{},
|
||||
{ key: 'domains.set_default', name: 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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-domain-badge {
|
||||
font-size: .75rem;
|
||||
padding-right: .2em;
|
||||
font-size: 0.75rem;
|
||||
padding-right: 0.2em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -15,18 +15,27 @@
|
|||
</BButton>
|
||||
</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 }">
|
||||
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mr-3">
|
||||
<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 v-if="parent" class="text-secondary">{{ parent.data.name }}</span>
|
||||
<span class="font-weight-bold">
|
||||
{{ data.name.replace(parent ? parent.data.name : null, '') }}
|
||||
</span>
|
||||
<span v-if="parent" class="text-secondary">
|
||||
{{ parent.data.name }}
|
||||
</span>
|
||||
</BLink>
|
||||
|
||||
<small
|
||||
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
|
||||
>
|
||||
<YIcon iname="star" />
|
||||
|
@ -43,47 +52,46 @@ import { mapGetters } from 'vuex'
|
|||
|
||||
import RecursiveListGroup from '@/components/RecursiveListGroup.vue'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'DomainList',
|
||||
|
||||
components: {
|
||||
RecursiveListGroup
|
||||
RecursiveListGroup,
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', { uri: 'domains', storeKey: 'domains' }]
|
||||
],
|
||||
queries: [['GET', { uri: 'domains', storeKey: 'domains' }]],
|
||||
search: '',
|
||||
domainsTree: undefined
|
||||
domainsTree: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['domains', 'mainDomain']),
|
||||
|
||||
tree () {
|
||||
tree() {
|
||||
if (!this.domainsTree) return
|
||||
if (this.search) {
|
||||
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
|
||||
},
|
||||
|
||||
hasFilteredItems () {
|
||||
hasFilteredItems() {
|
||||
if (!this.tree) return
|
||||
return this.tree.children || null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse () {
|
||||
onQueriesResponse() {
|
||||
// Add the tree to `data` to make it reactive
|
||||
this.domainsTree = this.$store.getters.domainsTree
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
<template>
|
||||
<CardForm
|
||||
:title="$t('group_new')" icon="users"
|
||||
:validation="$v" :server-error="serverError"
|
||||
:title="$t('group_new')"
|
||||
icon="users"
|
||||
:validation="$v"
|
||||
:server-error="serverError"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
|
@ -18,10 +24,10 @@ import { required, alphalownumdot_ } from '@/helpers/validators'
|
|||
export default {
|
||||
name: 'GroupCreate',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
groupname: ''
|
||||
groupname: '',
|
||||
},
|
||||
serverError: '',
|
||||
groupname: {
|
||||
|
@ -29,33 +35,35 @@ export default {
|
|||
description: this.$i18n.t('group_format_name_help'),
|
||||
props: {
|
||||
id: 'groupname',
|
||||
placeholder: this.$i18n.t('placeholder.groupname')
|
||||
}
|
||||
}
|
||||
placeholder: this.$i18n.t('placeholder.groupname'),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
validations: {
|
||||
form: {
|
||||
groupname: { required, alphalownumdot_ }
|
||||
}
|
||||
groupname: { required, alphalownumdot_ },
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSubmit () {
|
||||
api.post(
|
||||
{ uri: 'users/groups', storeKey: 'groups' },
|
||||
this.form,
|
||||
{ key: 'groups.create', name: this.form.groupname }
|
||||
).then(() => {
|
||||
this.$router.push({ name: 'group-list' })
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
this.serverError = err.message
|
||||
})
|
||||
}
|
||||
onSubmit() {
|
||||
api
|
||||
.post({ uri: 'users/groups', storeKey: 'groups' }, this.form, {
|
||||
key: 'groups.create',
|
||||
name: this.form.groupname,
|
||||
})
|
||||
.then(() => {
|
||||
this.$router.push({ name: 'group-list' })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
this.serverError = err.message
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [validationMixin]
|
||||
mixins: [validationMixin],
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -16,14 +16,23 @@
|
|||
|
||||
<!-- PRIMARY GROUPS CARDS -->
|
||||
<YCard
|
||||
v-for="(group, groupName) in filteredGroups" :key="groupName" collapsable
|
||||
:title="group.isSpecial ? $t('group_' + groupName) : `${$t('group')} '${groupName}'`" icon="group"
|
||||
v-for="(group, groupName) in filteredGroups"
|
||||
:key="groupName"
|
||||
collapsable
|
||||
:title="
|
||||
group.isSpecial
|
||||
? $t('group_' + groupName)
|
||||
: `${$t('group')} '${groupName}'`
|
||||
"
|
||||
icon="group"
|
||||
>
|
||||
<template #header-buttons>
|
||||
<!-- DELETE GROUP -->
|
||||
<BButton
|
||||
v-if="!group.isSpecial" @click="deleteGroup(groupName)"
|
||||
size="sm" variant="danger"
|
||||
v-if="!group.isSpecial"
|
||||
@click="deleteGroup(groupName)"
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
<YIcon iname="trash-o" /> {{ $t('delete') }}
|
||||
</BButton>
|
||||
|
@ -37,23 +46,29 @@
|
|||
<BCol>
|
||||
<template v-if="group.isSpecial">
|
||||
<p class="text-primary">
|
||||
<YIcon iname="info-circle" /> {{ $t('group_explain_' + groupName) }}
|
||||
<YIcon iname="info-circle" />
|
||||
{{ $t('group_explain_' + groupName) }}
|
||||
</p>
|
||||
<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>
|
||||
</template>
|
||||
<template v-if="groupName == 'admins' || !group.isSpecial">
|
||||
<TagsSelectizeItem
|
||||
v-model="group.members" :options="usersOptions"
|
||||
:id="groupName + '-users'" :label="$t('group_add_member')"
|
||||
tag-icon="user" items-name="users"
|
||||
v-model="group.members"
|
||||
:options="usersOptions"
|
||||
:id="groupName + '-users'"
|
||||
:label="$t('group_add_member')"
|
||||
tag-icon="user"
|
||||
items-name="users"
|
||||
@tag-update="onUserChanged({ ...$event, groupName })"
|
||||
/>
|
||||
</template>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<hr>
|
||||
<hr />
|
||||
|
||||
<BRow>
|
||||
<BCol md="3" lg="2">
|
||||
|
@ -61,9 +76,12 @@
|
|||
</BCol>
|
||||
<BCol>
|
||||
<TagsSelectizeItem
|
||||
v-model="group.permissions" :options="permissionsOptions"
|
||||
:id="groupName + '-perms'" :label="$t('group_add_permission')"
|
||||
tag-icon="key-modern" items-name="permissions"
|
||||
v-model="group.permissions"
|
||||
:options="permissionsOptions"
|
||||
:id="groupName + '-perms'"
|
||||
:label="$t('group_add_permission')"
|
||||
tag-icon="key-modern"
|
||||
items-name="permissions"
|
||||
@tag-update="onPermissionChanged({ ...$event, groupName })"
|
||||
:disabled-items="group.disabledItems"
|
||||
/>
|
||||
|
@ -73,8 +91,10 @@
|
|||
|
||||
<!-- USER GROUPS CARD -->
|
||||
<YCard
|
||||
v-if="userGroups" collapsable
|
||||
:title="$t('group_specific_permissions')" icon="group"
|
||||
v-if="userGroups"
|
||||
collapsable
|
||||
:title="$t('group_specific_permissions')"
|
||||
icon="group"
|
||||
>
|
||||
<template v-for="(userName, index) in activeUserGroups">
|
||||
<BRow :key="userName">
|
||||
|
@ -84,20 +104,28 @@
|
|||
|
||||
<BCol>
|
||||
<TagsSelectizeItem
|
||||
v-model="userGroups[userName].permissions" :options="permissionsOptions"
|
||||
:id="userName + '-perms'" :label="$t('group_add_permission')"
|
||||
tag-icon="key-modern" items-name="permissions"
|
||||
@tag-update="onPermissionChanged({ ...$event, groupName: userName })"
|
||||
v-model="userGroups[userName].permissions"
|
||||
:options="permissionsOptions"
|
||||
:id="userName + '-perms'"
|
||||
:label="$t('group_add_permission')"
|
||||
tag-icon="key-modern"
|
||||
items-name="permissions"
|
||||
@tag-update="
|
||||
onPermissionChanged({ ...$event, groupName: userName })
|
||||
"
|
||||
/>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<hr :key="index">
|
||||
<hr :key="index" />
|
||||
</template>
|
||||
|
||||
<TagsSelectizeItem
|
||||
v-model="activeUserGroups" :options="usersOptions"
|
||||
id="user-groups" :label="$t('group_add_member')"
|
||||
no-tags items-name="users"
|
||||
v-model="activeUserGroups"
|
||||
:options="usersOptions"
|
||||
id="user-groups"
|
||||
:label="$t('group_add_member')"
|
||||
no-tags
|
||||
items-name="users"
|
||||
@tag-update="onSpecificUserAdded"
|
||||
/>
|
||||
</YCard>
|
||||
|
@ -117,15 +145,21 @@ export default {
|
|||
name: 'GroupList',
|
||||
|
||||
components: {
|
||||
TagsSelectizeItem
|
||||
TagsSelectizeItem,
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['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: '',
|
||||
permissions: undefined,
|
||||
|
@ -133,12 +167,12 @@ export default {
|
|||
primaryGroups: undefined,
|
||||
userGroups: undefined,
|
||||
usersOptions: undefined,
|
||||
activeUserGroups: undefined
|
||||
activeUserGroups: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredGroups () {
|
||||
filteredGroups() {
|
||||
const groups = this.primaryGroups
|
||||
if (!groups) return
|
||||
const search = this.search.toLowerCase()
|
||||
|
@ -149,14 +183,17 @@ export default {
|
|||
}
|
||||
}
|
||||
return isEmptyValue(filtered) ? null : filtered
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (users, allGroups, permsDict) {
|
||||
onQueriesResponse(users, allGroups, permsDict) {
|
||||
// Do not use computed properties to get values from the store here to avoid auto
|
||||
// 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 primaryGroups = {}
|
||||
const userGroups = {}
|
||||
|
@ -173,90 +210,124 @@ export default {
|
|||
continue
|
||||
}
|
||||
|
||||
group.isSpecial = ['visitors', 'all_users', 'admins'].includes(groupName)
|
||||
group.isSpecial = ['visitors', 'all_users', 'admins'].includes(
|
||||
groupName,
|
||||
)
|
||||
|
||||
if (groupName === 'visitors') {
|
||||
// Forbid to add or remove a protected permission on group `visitors`
|
||||
group.disabledItems = permissions.filter(({ id }) => {
|
||||
return ['mail.main', 'xmpp.main'].includes(id) || permsDict[id].protected
|
||||
}).map(({ id }) => permsDict[id].label)
|
||||
group.disabledItems = permissions
|
||||
.filter(({ id }) => {
|
||||
return (
|
||||
['mail.main', 'xmpp.main'].includes(id) ||
|
||||
permsDict[id].protected
|
||||
)
|
||||
})
|
||||
.map(({ id }) => permsDict[id].label)
|
||||
}
|
||||
|
||||
if (groupName === 'all_users') {
|
||||
// Forbid to add ssh and sftp permission on group `all_users`
|
||||
group.disabledItems = permissions.filter(({ id }) => {
|
||||
return ['ssh.main', 'sftp.main'].includes(id)
|
||||
}).map(({ id }) => permsDict[id].label)
|
||||
group.disabledItems = permissions
|
||||
.filter(({ id }) => {
|
||||
return ['ssh.main', 'sftp.main'].includes(id)
|
||||
})
|
||||
.map(({ id }) => permsDict[id].label)
|
||||
}
|
||||
|
||||
if (groupName === 'admins') {
|
||||
// Forbid to add ssh and sftp permission on group `admins`
|
||||
group.disabledItems = permissions.filter(({ id }) => {
|
||||
return ['ssh.main', 'sftp.main'].includes(id)
|
||||
}).map(({ id }) => permsDict[id].label)
|
||||
group.disabledItems = permissions
|
||||
.filter(({ id }) => {
|
||||
return ['ssh.main', 'sftp.main'].includes(id)
|
||||
})
|
||||
.map(({ id }) => permsDict[id].label)
|
||||
}
|
||||
|
||||
primaryGroups[groupName] = group
|
||||
}
|
||||
|
||||
const activeUserGroups = Object.entries(userGroups).filter(([_, group]) => {
|
||||
return group.permissions.length > 0
|
||||
}).map(([name]) => name)
|
||||
const activeUserGroups = Object.entries(userGroups)
|
||||
.filter(([_, group]) => {
|
||||
return group.permissions.length > 0
|
||||
})
|
||||
.map(([name]) => name)
|
||||
|
||||
Object.assign(this, {
|
||||
permissions,
|
||||
permissionsOptions: permissions.map(perm => perm.label),
|
||||
permissionsOptions: permissions.map((perm) => perm.label),
|
||||
primaryGroups,
|
||||
userGroups: isEmptyValue(userGroups) ? null : userGroups,
|
||||
usersOptions: userNames,
|
||||
activeUserGroups
|
||||
activeUserGroups,
|
||||
})
|
||||
},
|
||||
|
||||
async onPermissionChanged ({ option, groupName, action, applyMethod }) {
|
||||
const permId = this.permissions.find(perm => perm.label === option).id
|
||||
async onPermissionChanged({ option, groupName, action, applyMethod }) {
|
||||
const permId = this.permissions.find((perm) => perm.label === option).id
|
||||
if (action === 'add' && ['sftp.main', 'ssh.main'].includes(permId)) {
|
||||
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
|
||||
}
|
||||
api.put(
|
||||
// FIXME hacky way to update the store
|
||||
{ uri: `users/permissions/${permId}/${action}/${groupName}`, storeKey: 'permissions', groupName, action, permId },
|
||||
{},
|
||||
{ key: 'permissions.' + action, perm: option, name: groupName }
|
||||
).then(() => applyMethod(option))
|
||||
api
|
||||
.put(
|
||||
// FIXME hacky way to update the store
|
||||
{
|
||||
uri: `users/permissions/${permId}/${action}/${groupName}`,
|
||||
storeKey: 'permissions',
|
||||
groupName,
|
||||
action,
|
||||
permId,
|
||||
},
|
||||
{},
|
||||
{ key: 'permissions.' + action, perm: option, name: groupName },
|
||||
)
|
||||
.then(() => applyMethod(option))
|
||||
},
|
||||
|
||||
onUserChanged ({ option, groupName, action, applyMethod }) {
|
||||
api.put(
|
||||
{ uri: `users/groups/${groupName}/${action}/${option}`, storeKey: 'groups', groupName },
|
||||
{},
|
||||
{ key: 'groups.' + action, user: option, name: groupName }
|
||||
).then(() => applyMethod(option))
|
||||
onUserChanged({ option, groupName, action, applyMethod }) {
|
||||
api
|
||||
.put(
|
||||
{
|
||||
uri: `users/groups/${groupName}/${action}/${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') {
|
||||
this.userGroups[userName].permissions = []
|
||||
applyMethod(userName)
|
||||
}
|
||||
},
|
||||
|
||||
async deleteGroup (groupName) {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: groupName }))
|
||||
async deleteGroup(groupName) {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_delete', { name: groupName }),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
api.delete(
|
||||
{ uri: 'users/groups', param: groupName, storeKey: 'groups' },
|
||||
{},
|
||||
{ key: 'groups.delete', name: groupName }
|
||||
).then(() => {
|
||||
Vue.delete(this.primaryGroups, groupName)
|
||||
})
|
||||
}
|
||||
}
|
||||
api
|
||||
.delete(
|
||||
{ uri: 'users/groups', param: groupName, storeKey: 'groups' },
|
||||
{},
|
||||
{ key: 'groups.delete', name: groupName },
|
||||
)
|
||||
.then(() => {
|
||||
Vue.delete(this.primaryGroups, groupName)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<template>
|
||||
<ViewBase
|
||||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
ref="view" skeleton="CardInfoSkeleton"
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
ref="view"
|
||||
skeleton="CardInfoSkeleton"
|
||||
>
|
||||
<!-- INFO CARD -->
|
||||
<YCard :title="name" icon="info-circle" button-unbreak="sm">
|
||||
|
@ -13,7 +15,11 @@
|
|||
</BButton>
|
||||
|
||||
<!-- 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') }}
|
||||
</BButton>
|
||||
</template>
|
||||
|
@ -25,11 +31,15 @@
|
|||
</template>
|
||||
|
||||
<BRow
|
||||
v-for="(value, key) in infos" :key="key"
|
||||
no-gutters class="row-line"
|
||||
v-for="(value, key) in infos"
|
||||
:key="key"
|
||||
no-gutters
|
||||
class="row-line"
|
||||
>
|
||||
<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>
|
||||
<template v-if="key === 'status'">
|
||||
|
@ -37,10 +47,13 @@
|
|||
<YIcon :iname="value === 'running' ? 'check-circle' : 'times'" />
|
||||
{{ $t(value) }}
|
||||
</span>
|
||||
{{ $t('since') }} {{ distanceToNow(uptime ) }}
|
||||
{{ $t('since') }} {{ distanceToNow(uptime) }}
|
||||
</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) }}
|
||||
</span>
|
||||
|
||||
|
@ -76,14 +89,14 @@ export default {
|
|||
name: 'ServiceInfo',
|
||||
|
||||
props: {
|
||||
name: { type: String, required: true }
|
||||
name: { type: String, required: true },
|
||||
},
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', 'services/' + this.name],
|
||||
['GET', `services/${this.name}/log?number=50`]
|
||||
['GET', `services/${this.name}/log?number=50`],
|
||||
],
|
||||
// Service data
|
||||
infos: undefined,
|
||||
|
@ -91,62 +104,71 @@ export default {
|
|||
isCritical: undefined,
|
||||
logs: undefined,
|
||||
// Modal action
|
||||
action: undefined
|
||||
action: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (
|
||||
onQueriesResponse(
|
||||
// eslint-disable-next-line
|
||||
{ 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
|
||||
this.uptime = last_state_change === 'unknown' ? 0 : last_state_change
|
||||
this.infos = { description, status, start_on_boot, configuration }
|
||||
|
||||
this.logs = Object.keys(logs).sort((prev, curr) => {
|
||||
if (prev === 'journalctl') return -1
|
||||
else if (curr === 'journalctl') return 1
|
||||
else if (prev < curr) return -1
|
||||
else return 1
|
||||
}).map(filename => ({ content: logs[filename].join('\n'), filename }))
|
||||
this.logs = Object.keys(logs)
|
||||
.sort((prev, curr) => {
|
||||
if (prev === 'journalctl') return -1
|
||||
else if (curr === 'journalctl') return 1
|
||||
else if (prev < curr) return -1
|
||||
else return 1
|
||||
})
|
||||
.map((filename) => ({ content: logs[filename].join('\n'), filename }))
|
||||
},
|
||||
|
||||
async updateService (action) {
|
||||
async updateService(action) {
|
||||
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
|
||||
|
||||
api.put(
|
||||
`services/${this.name}/${action}`,
|
||||
{},
|
||||
{ key: 'services.' + action, name: this.name }
|
||||
).then(this.$refs.view.fetchQueries)
|
||||
api
|
||||
.put(
|
||||
`services/${this.name}/${action}`,
|
||||
{},
|
||||
{ key: 'services.' + action, name: this.name },
|
||||
)
|
||||
.then(this.$refs.view.fetchQueries)
|
||||
},
|
||||
|
||||
shareLogs () {
|
||||
const logs = this.logs.map(({ filename, content }) => {
|
||||
return `LOGFILE: ${filename}\n${content}`
|
||||
}).join('\n\n')
|
||||
shareLogs() {
|
||||
const logs = this.logs
|
||||
.map(({ filename, content }) => {
|
||||
return `LOGFILE: ${filename}\n${content}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
fetch('https://paste.yunohost.org/documents', {
|
||||
method: 'POST',
|
||||
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')
|
||||
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')
|
||||
})
|
||||
},
|
||||
|
||||
distanceToNow
|
||||
}
|
||||
distanceToNow,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,8 +10,14 @@
|
|||
>
|
||||
<BListGroup>
|
||||
<BListGroupItem
|
||||
v-for="{ name, description, status, last_state_change } in filteredServices" :key="name"
|
||||
:to="{ name: 'service-info', params: { name }}"
|
||||
v-for="{
|
||||
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"
|
||||
>
|
||||
<div>
|
||||
|
@ -20,7 +26,9 @@
|
|||
<small class="text-secondary">{{ description }}</small>
|
||||
</h5>
|
||||
<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'" />
|
||||
{{ $t(status) }}
|
||||
</span>
|
||||
|
@ -40,48 +48,48 @@ import { distanceToNow } from '@/helpers/filters/date'
|
|||
export default {
|
||||
name: 'ServiceList',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', 'services']
|
||||
],
|
||||
queries: [['GET', 'services']],
|
||||
search: '',
|
||||
services: undefined
|
||||
services: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredServices () {
|
||||
filteredServices() {
|
||||
if (!this.services) return
|
||||
const search = this.search.toLowerCase()
|
||||
const services = this.services.filter(({ name }) => {
|
||||
return name.toLowerCase().includes(search)
|
||||
})
|
||||
return services.length ? services : null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (services) {
|
||||
this.services = Object.keys(services).sort().map(name => {
|
||||
const service = services[name]
|
||||
if (service.last_state_change === 'unknown') {
|
||||
service.last_state_change = 0
|
||||
}
|
||||
return { ...service, name }
|
||||
})
|
||||
onQueriesResponse(services) {
|
||||
this.services = Object.keys(services)
|
||||
.sort()
|
||||
.map((name) => {
|
||||
const service = services[name]
|
||||
if (service.last_state_change === 'unknown') {
|
||||
service.last_state_change = 0
|
||||
}
|
||||
return { ...service, name }
|
||||
})
|
||||
},
|
||||
|
||||
distanceToNow
|
||||
}
|
||||
distanceToNow,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@include media-breakpoint-down(md) {
|
||||
h5 small {
|
||||
display: block;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
h5 small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
<template>
|
||||
<ViewBase
|
||||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
ref="view" skeleton="CardFormSkeleton"
|
||||
:queries="queries"
|
||||
@queries-response="onQueriesResponse"
|
||||
ref="view"
|
||||
skeleton="CardFormSkeleton"
|
||||
>
|
||||
<!-- PORTS -->
|
||||
<YCard :title="$t('ports')" icon="shield">
|
||||
<div v-for="(items, protocol) in protocols" :key="protocol">
|
||||
<h5>{{ $t(protocol) }}</h5>
|
||||
|
||||
<BTable
|
||||
:fields="fields" :items="items"
|
||||
small striped responsive
|
||||
>
|
||||
<BTable :fields="fields" :items="items" small striped responsive>
|
||||
<!-- PORT CELL -->
|
||||
<template #cell(port)="data">
|
||||
{{ data.value }}
|
||||
|
@ -24,9 +23,21 @@
|
|||
class="on-off-switch"
|
||||
v-model="data.value"
|
||||
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') }}
|
||||
</span>
|
||||
</BFormCheckbox>
|
||||
|
@ -43,10 +54,13 @@
|
|||
|
||||
<!-- OPERATIONS -->
|
||||
<CardForm
|
||||
:title="$t('operations')" icon="cogs"
|
||||
:validation="$v" :server-error="serverError"
|
||||
:title="$t('operations')"
|
||||
icon="cogs"
|
||||
:validation="$v"
|
||||
:server-error="serverError"
|
||||
@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')">
|
||||
<BFormSelect v-model="form.action" :options="actionChoices" />
|
||||
|
@ -55,32 +69,49 @@
|
|||
<FormField :validation="$v.form.port">
|
||||
<BInputGroup :prepend="$t('port')">
|
||||
<InputItem
|
||||
id="input-port" placeholder="0" type="number"
|
||||
id="input-port"
|
||||
placeholder="0"
|
||||
type="number"
|
||||
v-model="form.port"
|
||||
/>
|
||||
</BInputGroup>
|
||||
</FormField>
|
||||
|
||||
<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 :prepend="$t('protocol')">
|
||||
<BFormSelect v-model="form.protocol" :options="protocolChoices" id="input-protocol" />
|
||||
<BFormSelect
|
||||
v-model="form.protocol"
|
||||
:options="protocolChoices"
|
||||
id="input-protocol"
|
||||
/>
|
||||
</BInputGroup>
|
||||
</CardForm>
|
||||
|
||||
<!-- UPnP -->
|
||||
<YCard :title="$t('upnp')" icon="exchange" :body-text-variant="upnpEnabled ? 'success' : 'danger'">
|
||||
{{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled' ) }}
|
||||
<YCard
|
||||
:title="$t('upnp')"
|
||||
icon="exchange"
|
||||
:body-text-variant="upnpEnabled ? 'success' : 'danger'"
|
||||
>
|
||||
{{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled') }}
|
||||
|
||||
<BFormInvalidFeedback :state="upnpError !== '' ? false : null">
|
||||
{{ upnpError }}
|
||||
</BFormInvalidFeedback>
|
||||
|
||||
<template #buttons>
|
||||
<BButton @click="toggleUpnp" :variant="!upnpEnabled ? 'success' : 'danger'">
|
||||
{{ $t(!upnpEnabled ? 'enable' : 'disable' ) }}
|
||||
<BButton
|
||||
@click="toggleUpnp"
|
||||
:variant="!upnpEnabled ? 'success' : 'danger'"
|
||||
>
|
||||
{{ $t(!upnpEnabled ? 'enable' : 'disable') }}
|
||||
</BButton>
|
||||
</template>
|
||||
</YCard>
|
||||
|
@ -96,11 +127,9 @@ import { required, integer, between } from '@/helpers/validators'
|
|||
export default {
|
||||
name: 'ToolFirewall',
|
||||
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
queries: [
|
||||
['GET', '/firewall?raw']
|
||||
],
|
||||
queries: [['GET', '/firewall?raw']],
|
||||
serverError: '',
|
||||
|
||||
// Ports tables data
|
||||
|
@ -108,7 +137,7 @@ export default {
|
|||
{ key: 'port', label: this.$i18n.t('port') },
|
||||
{ key: 'ipv4', label: this.$i18n.t('ipv4') },
|
||||
{ key: 'ipv6', label: this.$i18n.t('ipv6') },
|
||||
{ key: 'uPnP', label: this.$i18n.t('upnp') }
|
||||
{ key: 'uPnP', label: this.$i18n.t('upnp') },
|
||||
],
|
||||
protocols: undefined,
|
||||
portToToggle: undefined,
|
||||
|
@ -116,50 +145,53 @@ export default {
|
|||
// Ports form data
|
||||
actionChoices: [
|
||||
{ value: 'allow', text: this.$i18n.t('open') },
|
||||
{ value: 'disallow', text: this.$i18n.t('close') }
|
||||
{ value: 'disallow', text: this.$i18n.t('close') },
|
||||
],
|
||||
connectionChoices: [
|
||||
{ value: 'ipv4', text: this.$i18n.t('ipv4') },
|
||||
{ value: 'ipv6', text: this.$i18n.t('ipv6') }
|
||||
{ value: 'ipv6', text: this.$i18n.t('ipv6') },
|
||||
],
|
||||
protocolChoices: [
|
||||
{ value: 'TCP', text: this.$i18n.t('tcp') },
|
||||
{ value: 'UDP', text: this.$i18n.t('udp') },
|
||||
{ value: 'Both', text: this.$i18n.t('both') }
|
||||
{ value: 'Both', text: this.$i18n.t('both') },
|
||||
],
|
||||
form: {
|
||||
action: 'allow',
|
||||
port: undefined,
|
||||
connection: 'ipv4',
|
||||
protocol: 'TCP'
|
||||
protocol: 'TCP',
|
||||
},
|
||||
|
||||
// uPnP
|
||||
upnpEnabled: undefined,
|
||||
upnpError: ''
|
||||
upnpError: '',
|
||||
}
|
||||
},
|
||||
|
||||
validations: {
|
||||
form: {
|
||||
port: { number: required, integer, between: between(0, 65535) }
|
||||
}
|
||||
port: { number: required, integer, between: between(0, 65535) },
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (data) {
|
||||
const ports = Object.values(data).reduce((ports, protocols) => {
|
||||
for (const type of ['TCP', 'UDP']) {
|
||||
for (const port of protocols[type]) {
|
||||
ports[type].add(port)
|
||||
onQueriesResponse(data) {
|
||||
const ports = Object.values(data).reduce(
|
||||
(ports, protocols) => {
|
||||
for (const type of ['TCP', 'UDP']) {
|
||||
for (const port of protocols[type]) {
|
||||
ports[type].add(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}, { TCP: new Set(), UDP: new Set() })
|
||||
return ports
|
||||
},
|
||||
{ TCP: new Set(), UDP: new Set() },
|
||||
)
|
||||
|
||||
const tables = {
|
||||
TCP: [],
|
||||
UDP: []
|
||||
UDP: [],
|
||||
}
|
||||
for (const protocol of ['TCP', 'UDP']) {
|
||||
for (const port of ports[protocol]) {
|
||||
|
@ -169,89 +201,111 @@ export default {
|
|||
}
|
||||
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.upnpEnabled = data.uPnP.enabled
|
||||
},
|
||||
|
||||
async togglePort ({ action, port, protocol, connection }) {
|
||||
async togglePort({ action, port, protocol, connection }) {
|
||||
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) {
|
||||
return Promise.resolve(confirmed)
|
||||
}
|
||||
|
||||
const actionTrad = this.$i18n.t({ allow: 'open', disallow: 'close' }[action])
|
||||
return api.put(
|
||||
`firewall/${protocol}/${action}/${port}?${connection}_only`,
|
||||
{},
|
||||
{ key: 'firewall.ports', protocol, action: actionTrad, port, connection },
|
||||
{ wait: false }
|
||||
).then(() => confirmed)
|
||||
const actionTrad = this.$i18n.t(
|
||||
{ allow: 'open', disallow: 'close' }[action],
|
||||
)
|
||||
return api
|
||||
.put(
|
||||
`firewall/${protocol}/${action}/${port}?${connection}_only`,
|
||||
{},
|
||||
{
|
||||
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 confirmed = await this.$askConfirmation(this.$i18n.t('confirm_upnp_' + action))
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_upnp_' + action),
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
api.put(
|
||||
'firewall/upnp/' + action,
|
||||
{},
|
||||
{ key: 'firewall.upnp', action: this.$i18n.t(action) }
|
||||
).then(() => {
|
||||
// FIXME Couldn't test when it works.
|
||||
this.$refs.view.fetchQueries()
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
this.upnpError = err.message
|
||||
})
|
||||
api
|
||||
.put(
|
||||
'firewall/upnp/' + action,
|
||||
{},
|
||||
{ key: 'firewall.upnp', action: this.$i18n.t(action) },
|
||||
)
|
||||
.then(() => {
|
||||
// FIXME Couldn't test when it works.
|
||||
this.$refs.view.fetchQueries()
|
||||
})
|
||||
.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)
|
||||
const action = value ? 'allow' : 'disallow'
|
||||
this.togglePort({ action, port, protocol, connection }).then(toggled => {
|
||||
// Revert change on cancel
|
||||
if (!toggled) {
|
||||
this.$set(this.protocols[protocol][index], connection, !value)
|
||||
}
|
||||
})
|
||||
this.togglePort({ action, port, protocol, connection }).then(
|
||||
(toggled) => {
|
||||
// Revert change on cancel
|
||||
if (!toggled) {
|
||||
this.$set(this.protocols[protocol][index], connection, !value)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
onFormPortToggling (e) {
|
||||
this.togglePort(this.form).then(toggled => {
|
||||
onFormPortToggling(e) {
|
||||
this.togglePort(this.form).then((toggled) => {
|
||||
if (toggled) this.$refs.view.fetchQueries()
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [validationMixin]
|
||||
mixins: [validationMixin],
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .on-off-switch {
|
||||
.custom-control-input {
|
||||
&:checked ~ .custom-control-label::before {
|
||||
border-color: $success;
|
||||
background-color: $success;
|
||||
&:checked ~ .custom-control-label::before {
|
||||
border-color: $success;
|
||||
background-color: $success;
|
||||
}
|
||||
&:not(:checked) ~ .custom-control-label {
|
||||
&::before {
|
||||
border-color: $danger;
|
||||
background-color: $danger;
|
||||
}
|
||||
&:not(:checked) ~ .custom-control-label {
|
||||
&::before {
|
||||
border-color: $danger;
|
||||
background-color: $danger;
|
||||
}
|
||||
&::after {
|
||||
background-color: $white;
|
||||
}
|
||||
&::after {
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input:focus ~ .custom-control-label, &:hover {
|
||||
input:focus ~ .custom-control-label,
|
||||
&:hover {
|
||||
span {
|
||||
visibility: visible;
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue