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

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

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
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

View file

@ -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
View file

@ -0,0 +1 @@
dist/

19
app/.prettierrc Normal file
View file

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

View file

@ -1,23 +1,26 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<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>
</html>
<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

File diff suppressed because it is too large Load diff

View file

@ -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": [

View file

@ -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;

View file

@ -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)
})
}
},
}

View file

@ -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,
}

View file

@ -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)

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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;
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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;

View file

@ -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>

View file

@ -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;
}
}
}
}

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

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

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
@ -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

View file

@ -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

View file

@ -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 })
}

View file

@ -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()
})
}

View file

@ -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,
}

View file

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

View file

@ -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)

View file

@ -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,
}

View file

@ -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',
},
}

View file

@ -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')

View file

@ -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) => {

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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,
}
})
}
}
},
},
}

View file

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

View file

@ -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`
}
}
},
},
}

View file

@ -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 }
},
)
},
},
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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);
}
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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') }}&nbsp;<BLink :href="`#/domains/${domain.topest_parent}/dns`">
{{ $t('domain.see_parent_domain') }}&nbsp;<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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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