This commit is contained in:
Axolotle 2024-08-23 13:04:36 +00:00 committed by GitHub
commit 0cbed9fd14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
164 changed files with 12643 additions and 11165 deletions

View file

@ -5,14 +5,22 @@ module.exports = {
node: true,
},
extends: [
'plugin:vue/strongly-recommended',
'plugin:vue/vue3-recommended',
'eslint:recommended',
'@vue/eslint-config-typescript',
'plugin:prettier/recommended',
],
rules: {
'no-unused-vars': [
'vue/no-v-html': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
},
],
'no-console': ['error', { allow: ['warn', 'error'] }],
},
}

125
app/components.d.ts vendored Normal file
View file

@ -0,0 +1,125 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdressItem: typeof import('./src/components/globals/formItems/AdressItem.vue')['default']
AppCatalogSkeleton: typeof import('./src/components/globals/skeletons/AppCatalogSkeleton.vue')['default']
BAccordion: typeof import('bootstrap-vue-next')['BAccordion']
BAccordionItem: typeof import('bootstrap-vue-next')['BAccordionItem']
BBadge: typeof import('bootstrap-vue-next')['BBadge']
BBreadcrumb: typeof import('bootstrap-vue-next')['BBreadcrumb']
BBreadcrumbItem: typeof import('bootstrap-vue-next')['BBreadcrumbItem']
BButton: typeof import('bootstrap-vue-next')['BButton']
BButtonGroup: typeof import('bootstrap-vue-next')['BButtonGroup']
BButtonToolbar: typeof import('bootstrap-vue-next')['BButtonToolbar']
BCard: typeof import('bootstrap-vue-next')['BCard']
BCardBody: typeof import('bootstrap-vue-next')['BCardBody']
BCardGroup: typeof import('bootstrap-vue-next')['BCardGroup']
BCardHeader: typeof import('bootstrap-vue-next')['BCardHeader']
BCardText: typeof import('bootstrap-vue-next')['BCardText']
BCardTitle: typeof import('bootstrap-vue-next')['BCardTitle']
BCol: typeof import('bootstrap-vue-next')['BCol']
BCollapse: typeof import('bootstrap-vue-next')['BCollapse']
BDropdown: typeof import('bootstrap-vue-next')['BDropdown']
BDropdownDivider: typeof import('bootstrap-vue-next')['BDropdownDivider']
BDropdownForm: typeof import('bootstrap-vue-next')['BDropdownForm']
BDropdownGroup: typeof import('bootstrap-vue-next')['BDropdownGroup']
BDropdownItem: typeof import('bootstrap-vue-next')['BDropdownItem']
BDropdownItemButton: typeof import('bootstrap-vue-next')['BDropdownItemButton']
BDropdownText: typeof import('bootstrap-vue-next')['BDropdownText']
BForm: typeof import('bootstrap-vue-next')['BForm']
BFormCheckbox: typeof import('bootstrap-vue-next')['BFormCheckbox']
BFormCheckboxGroup: typeof import('bootstrap-vue-next')['BFormCheckboxGroup']
BFormFile: typeof import('bootstrap-vue-next')['BFormFile']
BFormGroup: typeof import('bootstrap-vue-next')['BFormGroup']
BFormInput: typeof import('bootstrap-vue-next')['BFormInput']
BFormInvalidFeedback: typeof import('bootstrap-vue-next')['BFormInvalidFeedback']
BFormRadio: typeof import('bootstrap-vue-next')['BFormRadio']
BFormRadioGroup: typeof import('bootstrap-vue-next')['BFormRadioGroup']
BFormSelect: typeof import('bootstrap-vue-next')['BFormSelect']
BFormSelectOption: typeof import('bootstrap-vue-next')['BFormSelectOption']
BFormTag: typeof import('bootstrap-vue-next')['BFormTag']
BFormTags: typeof import('bootstrap-vue-next')['BFormTags']
BFormTextarea: typeof import('bootstrap-vue-next')['BFormTextarea']
BImg: typeof import('bootstrap-vue-next')['BImg']
BInputGroup: typeof import('bootstrap-vue-next')['BInputGroup']
BInputGroupText: typeof import('bootstrap-vue-next')['BInputGroupText']
BLink: typeof import('bootstrap-vue-next')['BLink']
BListGroup: typeof import('bootstrap-vue-next')['BListGroup']
BListGroupItem: typeof import('bootstrap-vue-next')['BListGroupItem']
BModal: typeof import('bootstrap-vue-next')['BModal']
BModalOrchestrator: typeof import('bootstrap-vue-next')['BModalOrchestrator']
BNav: typeof import('bootstrap-vue-next')['BNav']
BNavbar: typeof import('bootstrap-vue-next')['BNavbar']
BNavbarBrand: typeof import('bootstrap-vue-next')['BNavbarBrand']
BNavbarNav: typeof import('bootstrap-vue-next')['BNavbarNav']
BNavItem: typeof import('bootstrap-vue-next')['BNavItem']
BNavText: typeof import('bootstrap-vue-next')['BNavText']
BOverlay: typeof import('bootstrap-vue-next')['BOverlay']
BPopover: typeof import('bootstrap-vue-next')['BPopover']
BProgress: typeof import('bootstrap-vue-next')['BProgress']
BProgressBar: typeof import('bootstrap-vue-next')['BProgressBar']
BRow: typeof import('bootstrap-vue-next')['BRow']
BSkeleton: typeof import('./src/components/globals/skeletons/BSkeleton.vue')['default']
BSkeletonWrapper: typeof import('./src/components/globals/skeletons/BSkeletonWrapper.vue')['default']
BTab: typeof import('bootstrap-vue-next')['BTab']
BTable: typeof import('bootstrap-vue-next')['BTable']
BTabs: typeof import('bootstrap-vue-next')['BTabs']
ButtonItem: typeof import('./src/components/globals/formItems/ButtonItem.vue')['default']
CardCollapse: typeof import('./src/components/CardCollapse.vue')['default']
CardDeckFeed: typeof import('./src/components/CardDeckFeed.vue')['default']
CardForm: typeof import('./src/components/globals/CardForm.vue')['default']
CardFormSkeleton: typeof import('./src/components/globals/skeletons/CardFormSkeleton.vue')['default']
CardInfoSkeleton: typeof import('./src/components/globals/skeletons/CardInfoSkeleton.vue')['default']
CardListSkeleton: typeof import('./src/components/globals/skeletons/CardListSkeleton.vue')['default']
CheckboxItem: typeof import('./src/components/globals/formItems/CheckboxItem.vue')['default']
ConfigPanels: typeof import('./src/components/ConfigPanels.vue')['default']
DescriptionRow: typeof import('./src/components/globals/DescriptionRow.vue')['default']
DisplayTextItem: typeof import('./src/components/globals/formItems/DisplayTextItem.vue')['default']
ExplainWhat: typeof import('./src/components/globals/ExplainWhat.vue')['default']
FileItem: typeof import('./src/components/globals/formItems/FileItem.vue')['default']
FormField: typeof import('./src/components/globals/FormField.vue')['default']
FormFieldMultiple: typeof import('./src/components/globals/FormFieldMultiple.vue')['default']
FormFieldReadonly: typeof import('./src/components/globals/FormFieldReadonly.vue')['default']
InputItem: typeof import('./src/components/globals/formItems/InputItem.vue')['default']
LazyRenderer: typeof import('./src/components/LazyRenderer.vue')['default']
ListGroupSkeleton: typeof import('./src/components/globals/skeletons/ListGroupSkeleton.vue')['default']
MainLayout: typeof import('./src/components/layouts/MainLayout.vue')['default']
MarkdownItem: typeof import('./src/components/globals/formItems/MarkdownItem.vue')['default']
MessageListGroup: typeof import('./src/components/MessageListGroup.vue')['default']
ModalError: typeof import('./src/components/modals/ModalError.vue')['default']
ModalOverlay: typeof import('./src/components/modals/ModalOverlay.vue')['default']
ModalReconnecting: typeof import('./src/components/modals/ModalReconnecting.vue')['default']
ModalWaiting: typeof import('./src/components/modals/ModalWaiting.vue')['default']
ModalWarning: typeof import('./src/components/modals/ModalWarning.vue')['default']
QueryHeader: typeof import('./src/components/QueryHeader.vue')['default']
ReadOnlyAlertItem: typeof import('./src/components/globals/formItems/ReadOnlyAlertItem.vue')['default']
RecursiveListGroup: typeof import('./src/components/RecursiveListGroup.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SelectItem: typeof import('./src/components/globals/formItems/SelectItem.vue')['default']
TagsItem: typeof import('./src/components/globals/formItems/TagsItem.vue')['default']
TagsSelectizeItem: typeof import('./src/components/globals/formItems/TagsSelectizeItem.vue')['default']
TextAreaItem: typeof import('./src/components/globals/formItems/TextAreaItem.vue')['default']
TopBar: typeof import('./src/components/globals/TopBar.vue')['default']
ViewSearch: typeof import('./src/components/globals/ViewSearch.vue')['default']
YAlert: typeof import('./src/components/globals/YAlert.vue')['default']
YBreadcrumb: typeof import('./src/components/globals/YBreadcrumb.vue')['default']
YCard: typeof import('./src/components/globals/YCard.vue')['default']
YIcon: typeof import('./src/components/globals/YIcon.vue')['default']
YListGroupItem: typeof import('./src/components/globals/YListGroupItem.vue')['default']
YListItem: typeof import('./src/components/globals/YListItem.vue')['default']
YSpinner: typeof import('./src/components/globals/YSpinner.vue')['default']
}
export interface ComponentCustomProperties {
vBModal: typeof import('bootstrap-vue-next')['vBModal']
vBPopover: typeof import('bootstrap-vue-next')['vBPopover']
vBToggle: typeof import('bootstrap-vue-next')['vBToggle']
vBTooltip: typeof import('bootstrap-vue-next')['vBTooltip']
}
}

1
app/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -22,6 +22,6 @@
</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

22
app/overrides.d.ts vendored Normal file
View file

@ -0,0 +1,22 @@
import type { Skeleton } from '@/types/commons'
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
noAuth?: boolean
routerParams?: string[]
args: { trad?: string; param?: string }
breadcrumb?: string[]
skeleton?: (Skeleton | string)[] | Skeleton | string
}
}
declare module 'bootstrap-vue-next' {
interface BaseColorVariant {
best: unknown
}
interface BaseSize {
// `xs` size is available only for BButton
xs: unknown
}
}

View file

@ -10,34 +10,41 @@
"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"
"lintfix": "prettier --write --list-different . && yarn lint:js --fix",
"type-check": "vue-tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@fontsource/fira-code": "^4.5.13",
"@fontsource/firago": "^4.5.3",
"bootstrap-vue": "^2.22.0",
"date-fns": "^2.29.3",
"@fontsource/fira-code": "^5.0.18",
"@fontsource/firago": "^5.0.11",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"@vueuse/core": "^11.0.1",
"bootstrap": "^5.3.3",
"bootstrap-vue-next": "^0.24.7",
"date-fns": "^3.6.0",
"fork-awesome": "^1.2.0",
"simple-evaluate": "^1.4.6",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",
"vue-router": "^3.6.5",
"vue-showdown": "^2.4.1",
"vuelidate": "^0.7.7",
"vuex": "^3.6.2"
"uuid": "^10.0.0",
"vue": "^3.4.37",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
"vue-showdown": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue2": "^2.2.0",
"bootstrap": "^4.6.0",
"eslint": "^8.36.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-vue": "^5.1.2",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.57.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",
"vite": "^4.5.3"
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.27.0",
"prettier": "^3.3.3",
"sass": "^1.77.8",
"typescript": "^5.5.4",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.0",
"vue-tsc": "^2.0.29"
},
"browserslist": [
"> 1%",

View file

@ -1,3 +1,72 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useInfos } from '@/composables/useInfos'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import { HistoryConsole } from '@/views/_partials'
const { ssoLink, connected, yunohost, logout, onAppCreated } = useInfos()
const { locked } = useRequests()
const { spinner, dark } = useSettings()
const ready = ref(false)
onAppCreated().then(() => (ready.value = true))
onMounted(() => {
const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp']
let copypastastep = 0
document.addEventListener('keydown', ({ key }) => {
if (key === copypastaCode[copypastastep++]) {
if (copypastastep === copypastaCode.length) {
document
.querySelectorAll('.unselectable')
.forEach((element) => element.classList.remove('unselectable'))
copypastastep = 0
}
} else {
copypastastep = 0
}
})
// Konamicode ;P
const konamiCode = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'b',
'a',
]
let konamistep = 0
document.addEventListener('keydown', ({ key }) => {
if (key === konamiCode[konamistep++]) {
if (konamistep === konamiCode.length) {
spinner.value = 'nyancat'
konamistep = 0
}
} else {
konamistep = 0
}
})
// April fools easter egg ;)
const today = new Date()
if (today.getDate() === 1 && today.getMonth() + 1 === 4) {
spinner.value = 'magikarp'
}
// Halloween easter egg ;)
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
spinner.value = 'spookycat'
}
})
</script>
<template>
<div id="app" class="container">
<!-- HEADER -->
@ -5,11 +74,10 @@
<BNavbar>
<BNavbarBrand
:to="{ name: 'home' }"
:disabled="waiting"
exact
:disabled="locked"
exact-active-class="active"
>
<span v-if="theme">
<span v-if="dark">
<img alt="YunoHost logo" src="./assets/logo_light.png" width="40" />
</span>
<span v-else>
@ -17,19 +85,24 @@
</span>
</BNavbarBrand>
<BNavbarNav class="ml-auto">
<BNavbarNav class="ms-auto">
<li class="nav-item">
<BButton :href="ssoLink" variant="primary" size="sm" block>
<BButton
:href="ssoLink"
variant="primary"
size="sm"
class="d-block"
>
{{ $t('user_interface_link') }} <YIcon iname="user" />
</BButton>
</li>
<li class="nav-item" v-show="connected">
<li v-show="connected" class="nav-item">
<BButton
@click.prevent="logout"
variant="outline-dark"
block
size="sm"
@click.prevent="logout"
>
{{ $t('logout') }} <YIcon iname="sign-out" />
</BButton>
@ -39,23 +112,15 @@
</header>
<!-- MAIN -->
<ViewLockOverlay>
<YBreadcrumb />
<MainLayout v-if="ready" />
<main id="main">
<!-- The `key` on RouterView make sure that if a link points to a page that
use the same component as the previous one, it will be refreshed -->
<Transition v-if="transitions" :name="transitionName">
<RouterView class="animated" :key="routerKey" />
</Transition>
<RouterView v-else class="static" :key="routerKey" />
</main>
</ViewLockOverlay>
<BModalOrchestrator />
<!-- HISTORY CONSOLE -->
<HistoryConsole />
<!-- FOOTER -->
<div class="mt-4" />
<footer class="py-3 mt-auto">
<nav>
<BNav class="justify-content-center">
@ -84,7 +149,7 @@
<BNavText
v-if="yunohost"
id="yunohost-version"
class="ml-md-auto text-center"
class="ms-md-auto text-center"
>
<span v-html="$t('footer_version', yunohost)" />
</BNavText>
@ -94,106 +159,6 @@
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { HistoryConsole, ViewLockOverlay } from '@/views/_partials'
export default {
name: 'App',
components: {
HistoryConsole,
ViewLockOverlay,
},
computed: {
...mapGetters([
'connected',
'yunohost',
'routerKey',
'transitions',
'transitionName',
'waiting',
'theme',
'ssoLink',
]),
},
methods: {
async logout() {
this.$store.dispatch('LOGOUT')
},
},
// This hook is only triggered at page first load
created() {
this.$store.dispatch('ON_APP_CREATED')
},
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
.querySelectorAll('.unselectable')
.forEach((element) => element.classList.remove('unselectable'))
copypastastep = 0
}
} else {
copypastastep = 0
}
})
// Konamicode ;P
const konamiCode = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'b',
'a',
]
let konamistep = 0
document.addEventListener('keydown', ({ key }) => {
if (key === konamiCode[konamistep++]) {
if (konamistep === konamiCode.length) {
this.$store.commit('SET_SPINNER', 'nyancat')
konamistep = 0
}
} else {
konamistep = 0
}
})
// April fools easter egg ;)
const today = new Date()
if (today.getDate() === 1 && today.getMonth() + 1 === 4) {
this.$store.commit('SET_SPINNER', 'magikarp')
}
// Halloween easter egg ;)
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
this.$store.commit('SET_SPINNER', 'spookycat')
}
document.documentElement.setAttribute('dark-theme', this.theme) // updates the data-theme attribute
},
}
</script>
<style lang="scss">
// Global import of Bootstrap and custom styles
@import '@/scss/main.scss';
</style>
<style lang="scss" scoped>
// generic style for <html>, <body> and <#app> is in `scss/main.scss`
header {
@ -218,34 +183,6 @@ header {
}
}
main {
position: relative;
// Routes transition
.animated {
transition: all 0.15s ease-in-out;
}
.slide-left-enter,
.slide-right-leave-active {
position: absolute;
width: 100%;
top: 0;
transform: translate(100vw, 0);
}
.slide-left-leave-active,
.slide-right-enter {
position: absolute;
width: 100%;
top: 0;
transform: translate(-100vw, 0);
}
// hack to hide last transition provoqued by the <RouterView> element change
// while disabling the transitions in ToolWebAdmin
.static ~ .animated {
display: none;
}
}
#console {
// Allows the console to be tabbed before the footer links while remaining visually
// the last element of the page
@ -255,7 +192,6 @@ main {
footer {
border-top: $thin-border;
font-size: $font-size-sm;
margin-top: 2rem;
.nav-item {
& + .nav-item a::before {

View file

@ -1,238 +0,0 @@
/**
* API module.
* @module api
*/
import store from '@/store'
import { openWebSocket, getResponseData, handleError } from './handlers'
/**
* Options available for an API call.
*
* @typedef {Object} Options
* @property {Boolean} wait - If `true`, will display the waiting modal.
* @property {Boolean} websocket - if `true`, will open a websocket connection.
* @property {Boolean} initial - if `true` and an error occurs, the dismiss button will trigger a go back in history.
* @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`
*
* @typedef {Array} Query
* @property {String} 0 - "method"
* @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
* query string or used as a body in a `fetch` call.
*
* @param {Object} obj - An object literal to convert.
* @param {Object} options
* @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()
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
value.forEach((v) => urlParams.append(key, v))
} else {
urlParams.append(key, value)
}
}
if (addLocale) {
urlParams.append('locale', store.getters.locale)
}
return urlParams
}
export default {
options: {
credentials: 'include',
mode: 'cors',
headers: {
// FIXME is it important to keep this previous `Accept` header ?
// 'Accept': 'application/json, text/javascript, */*; q=0.01',
// Auto header is :
// "Accept": "*/*",
'X-Requested-With': 'XMLHttpRequest',
},
},
/**
* Generic method to fetch the api without automatic response handling.
*
* @param {String} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'.
* @param {String} uri
* @param {Object} [data={}] - data to send as body.
* @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(
method,
uri,
data = {},
humanKey = null,
{ 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,
})
if (websocket) {
await openWebSocket(request)
}
let options = this.options
if (method === 'GET') {
uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`
} else {
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)
},
/**
* Api multiple queries helper.
* Those calls will act as one (declare optional waiting for one but still create history entries for each)
* Calls are synchronous since the API can't handle multiple calls.
*
* @param {Array<Query>} queries - An array of queries with special representation.
* @param {Object} [options={}]
* @param {Boolean}
* @return {Promise<Array|Error>} Promise that resolve the api responses data or an error.
*/
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),
)
}
} finally {
// Stop waiting even if there is an error.
if (wait) store.commit('SET_WAITING', false)
}
return results
},
/**
* Api get helper function.
*
* @param {String|Object} uri
* @param {null} [data=null] - for convenience in muliple calls, just pass null.
* @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 = {}) {
options = { websocket: false, wait: false, ...options }
if (typeof uri === 'string')
return this.fetch('GET', uri, null, humanKey, options)
return store.dispatch('GET', { ...uri, humanKey, options })
},
/**
* Api post helper function.
*
* @param {String|Object} uri
* @param {String} [data={}] - data to send as body.
* @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)
return store.dispatch('POST', { ...uri, data, humanKey, options })
},
/**
* Api put helper function.
*
* @param {String|Object} uri
* @param {String} [data={}] - data to send as body.
* @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)
return store.dispatch('PUT', { ...uri, data, humanKey, options })
},
/**
* Api delete helper function.
*
* @param {String|Object} uri
* @param {String} [data={}] - data to send as body.
* @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)
return store.dispatch('DELETE', { ...uri, data, humanKey, options })
},
/**
* Api reconnection helper. Resolve when server is reachable or fail after n attemps
*
* @param {Number} attemps - number of attemps before rejecting
* @param {Number} delay - delay between calls to the API in ms.
* @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 } = {}) {
return new Promise((resolve, reject) => {
const api = this
function reconnect(n) {
store
.dispatch('GET_YUNOHOST_INFOS')
.then(resolve)
.catch((err) => {
if (err.name === 'APIUnauthorizedError') {
reject(err)
} else if (n < 1) {
reject(err)
} else {
setTimeout(() => reconnect(n - 1), delay)
}
})
}
if (initialDelay > 0) setTimeout(() => reconnect(attemps), initialDelay)
else reconnect(attemps)
})
},
}

284
app/src/api/api.ts Normal file
View file

@ -0,0 +1,284 @@
import { useCache, type StorePath } from '@/composables/data'
import { useInfos } from '@/composables/useInfos'
import {
useRequests,
type APIRequestAction,
type ReconnectingArgs,
} from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import type { Obj } from '@/types/commons'
import {
APIBadRequestError,
APIUnauthorizedError,
type APIError,
} from './errors'
import { getError, getResponseData, openWebSocket } from './handlers'
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
export type HumanKey = {
key: string
[propName: string]: any
}
export type APIQuery = {
method?: RequestMethod
uri: string
cachePath?: StorePath
data?: Obj
humanKey?: string | HumanKey
showModal?: boolean
websocket?: boolean
initial?: boolean
asFormData?: boolean
}
export type APIErrorData = {
error: string
error_key?: string
log_ref?: string
traceback?: string
name?: string
}
/**
* Converts an object literal into an `URLSearchParams` that can be turned into a
* query string or used as a body in a `fetch` call.
*
* @param obj - An object literal to convert to `FormData` or `URLSearchParams`
* @param addLocale - Append the locale to the returned object
* @param formData - Returns a `FormData` instead of `URLSearchParams`
*/
export function objectToParams(
obj: 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))
} else {
urlParams.append(key, value)
}
}
if (addLocale) {
const { locale } = useSettings()
urlParams.append('locale', locale.value)
}
return urlParams
}
export default {
options: {
credentials: 'include',
mode: 'cors',
headers: {
// FIXME is it important to keep this previous `Accept` header ?
// 'Accept': 'application/json, text/javascript, */*; q=0.01',
// Auto header is :
// "Accept": "*/*",
'X-Requested-With': 'XMLHttpRequest',
},
} as RequestInit,
/**
* Generic method to fetch the api.
*
* @param uri - URI to fetch
* @param cachePath - Cache path to get or store data
* @param cacheParams - Cache params to get or update data
* @param method - An HTTP method in `'GET' | 'POST' | 'PUT' | 'DELETE'`
* @param data - Data to send as body
* @param humanKey - Key and eventually some data to build the query's description
* @param showModal - Lock view and display the waiting modal
* @param websocket - Open a websocket connection to receive server messages
* @param initial - If an error occurs, the dismiss button will trigger a go back in history
* @param asFormData - Send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`)
*
* @returns Promise that resolve the api response data
* @throws Throw an `APIError` or subclass depending on server response
*/
async fetch<T extends any = any>({
uri,
method = 'GET',
cachePath = undefined,
data = undefined,
humanKey = undefined,
showModal = method !== 'GET',
websocket = method !== 'GET',
initial = false,
asFormData = true,
}: APIQuery): Promise<T> {
const cache = cachePath ? useCache<T>(method, cachePath) : undefined
if (method === 'GET' && cache?.content.value !== undefined) {
return cache.content.value
}
const { locale } = useSettings()
const { startRequest, endRequest } = useRequests()
const request = startRequest({
method,
uri,
humanKey,
initial,
showModal,
websocket,
})
if (websocket) {
await openWebSocket(request as APIRequestAction)
}
let options = { ...this.options }
if (method === 'GET') {
uri += `${uri.includes('?') ? '&' : '?'}locale=${locale.value}`
} else {
options = {
...options,
method,
body: data
? objectToParams(data, { addLocale: true, formData: asFormData })
: null,
}
}
const response = await fetch('/yunohost/api/' + uri, options)
if (!response.ok) {
const errorData = await getResponseData<string | APIErrorData>(response)
const err = getError(request, response, errorData)
endRequest({
request,
success: false,
isFormError: err instanceof APIBadRequestError,
})
throw err
}
const responseData = await getResponseData<T>(response)
cache?.update(responseData)
endRequest({ request, success: true })
if (cache) return cache.content.value as T
return responseData
},
/**
* Api multiple queries helper.
* Those calls will act as one (declare optional waiting for one but still create history entries for each)
* Calls are synchronous since the API can't handle multiple calls.
*
* @param queries - Array of {@link APIQuery}
* @param showModal - Show the waiting modal until every queries have been resolved
* @param initial - Inform that thoses queries are required for a view to be displayed
*
* @returns Promise that resolves an array of server responses
* @throws Throw an `APIError` or subclass depending on server response
*/
async fetchAll<T extends any[] = any[]>(
queries: APIQuery[],
{ showModal = false, initial = true } = {},
): Promise<T> {
const results = []
for (const query of queries) {
if (showModal) query.showModal = true
if (initial) query.initial = true
results.push(await this.fetch(query))
}
return results as T
},
/**
* Api get helper function.
*
* @param query - a simple string for uri or complete APIQuery object {@link APIQuery}
*
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/
get<T extends any = any>(
query: string | Omit<APIQuery, 'method' | 'data'>,
): Promise<T> {
return this.fetch(typeof query === 'string' ? { uri: query } : query)
},
/**
* Api post helper function.
*
* @param query - {@link APIQuery}
*
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/
post<T extends any = any>(query: Omit<APIQuery, 'method'>): Promise<T> {
return this.fetch({ ...query, method: 'POST' })
},
/**
* Api put helper function.
*
* @param query - {@link APIQuery}
*
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/
put<T extends any = any>(query: Omit<APIQuery, 'method'>): Promise<T> {
return this.fetch({ ...query, method: 'PUT' })
},
/**
* Api delete helper function.
*
* @param query - {@link APIQuery}
*
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/
delete<T extends any = any>(query: Omit<APIQuery, 'method'>): Promise<T> {
return this.fetch({ ...query, method: 'DELETE' })
},
refetch() {
// To force a view to reload and refetch initial data, we simply fake update
// the router key
const { updateRouterKey } = useInfos()
updateRouterKey()
},
/**
* Api reconnection helper. Resolve when server is reachable or fail after n attemps
*
* @param attemps - Number of attemps before rejecting
* @param delay - Delay between calls to the API in ms
* @param initialDelay - Delay before calling the API for the first time in ms
*
* @returns Promise that resolve yunohost version infos
* @throws Throw an `APIError` or subclass depending on server response
*/
tryToReconnect({
attemps = 5,
delay = 2000,
initialDelay = 0,
}: ReconnectingArgs = {}) {
const { getYunoHostVersion } = useInfos()
return new Promise((resolve, reject) => {
function reconnect(n: number) {
getYunoHostVersion()
.then(resolve)
.catch((err: APIError) => {
if (err instanceof APIUnauthorizedError) {
reject(err)
} else if (n < 1) {
reject(err)
} else {
setTimeout(() => reconnect(n - 1), delay)
}
})
}
if (initialDelay > 0) setTimeout(() => reconnect(attemps), initialDelay)
else reconnect(attemps)
})
},
}

View file

@ -1,113 +0,0 @@
/**
* API errors definitionss.
* @module api/errors
*/
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'),
)
const urlObj = new URL(url)
this.name = 'APIError'
this.code = status
this.status = statusText
this.method = request.method
this.request = request
this.path = urlObj.pathname + urlObj.search
}
log() {
/* eslint-disable-next-line */
console.error(`${this.name} (${this.code}): ${this.uri}\n${this.message}`)
}
}
// Log (Special error to trigger a redirect to a log page)
class APIErrorLog extends APIError {
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) {
super(method, response, { error: i18n.t('error_connection_interrupted') })
this.name = 'APIConnexionError'
}
}
// 400 — Bad Request
class APIBadRequestError extends APIError {
constructor(method, response, errorData) {
super(method, response, errorData)
this.name = 'APIBadRequestError'
this.key = errorData.error_key
this.data = errorData
}
}
// 401 — Unauthorized
class APIUnauthorizedError extends APIError {
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) {
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) {
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) {
super(method, response, { error: i18n.t('api_not_responding') })
this.name = 'APINotRespondingError'
}
}
// Temp factory
const errors = {
[undefined]: APIError,
log: APIErrorLog,
0: APIConnexionError,
400: APIBadRequestError,
401: APIUnauthorizedError,
404: APINotFoundError,
500: APIInternalError,
502: APINotRespondingError,
}
export {
errors as default,
APIError,
APIErrorLog,
APIBadRequestError,
APIConnexionError,
APIInternalError,
APINotFoundError,
APINotRespondingError,
APIUnauthorizedError,
}

165
app/src/api/errors.ts Normal file
View file

@ -0,0 +1,165 @@
/**
* API errors definitionss.
* @module api/errors
*/
import type { APIRequest } from '@/composables/useRequests'
import i18n from '@/i18n'
import type { APIErrorData, RequestMethod } from './api'
class APIError extends Error {
name = 'APIError'
code: number
status: string
method: RequestMethod
requestId: string
path: string
constructor(
request: APIRequest,
{ url, status, statusText }: Response,
{ error }: APIErrorData,
) {
super(
error
? error.replaceAll('\n', '<br>')
: i18n.global.t('error_server_unexpected'),
)
const urlObj = new URL(url)
this.code = status
this.status = statusText
this.method = request.method
this.requestId = request.id
this.path = urlObj.pathname + urlObj.search
}
log() {
/* eslint-disable-next-line */
console.error(`${this.name} (${this.code}): ${this.path}\n${this.message}`)
}
}
// Log (Special error to trigger a redirect to a log page)
class APIErrorLog extends APIError {
name = 'APIErrorLog'
logRef: string
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.logRef = errorData.log_ref as string
}
}
// 0 — (means "the connexion has been closed" apparently)
class APIConnexionError extends APIError {
name = 'APIConnexionError'
constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, {
error: i18n.global.t('error_connection_interrupted'),
})
}
}
// 400 — Bad Request
class APIBadRequestError extends APIError {
name = 'APIBadRequestError'
key: string
data: APIErrorData
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.key = errorData.error_key as string
this.data = errorData
}
}
// 401 — Unauthorized
class APIUnauthorizedError extends APIError {
name = 'APIUnauthorizedError'
constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, { error: i18n.global.t('unauthorized') })
}
}
// 404 — Not Found
class APINotFoundError extends APIError {
name = 'APINotFoundError'
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
errorData.error = i18n.global.t('api_not_found')
super(request, response, errorData)
}
}
// 500 — Server Internal Error
class APIInternalError extends APIError {
name = 'APIInternalError'
traceback: string | null
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.traceback = errorData.traceback || null
}
}
// 502 — Bad gateway (means API is down)
class APINotRespondingError extends APIError {
name = 'APINotRespondingError'
constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, { error: i18n.global.t('api_not_responding') })
}
}
// Temp factory
const errors = {
default: APIError,
log: APIErrorLog,
0: APIConnexionError,
400: APIBadRequestError,
401: APIUnauthorizedError,
404: APINotFoundError,
500: APIInternalError,
502: APINotRespondingError,
}
export {
APIBadRequestError,
APIConnexionError,
APIError,
APIErrorLog,
APIInternalError,
APINotFoundError,
APINotRespondingError,
APIUnauthorizedError,
errors as default,
}

View file

@ -1,108 +0,0 @@
/**
* API handlers.
* @module api/handlers
*/
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) {
// FIXME the api should always return json as response
const responseText = await response.text()
try {
return JSON.parse(responseText)
} catch {
return responseText
}
}
/**
* 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
* we have to open it for every calls.
* Messages are dispatch to the store so it can handle them.
*
* @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`,
)
ws.onmessage = ({ data }) => {
store.dispatch('DISPATCH_MESSAGE', {
request,
messages: JSON.parse(data),
})
}
// ws.onclose = (e) => {}
ws.onopen = resolve
// Resolve also on error so the actual fetch may be called.
ws.onerror = resolve
})
}
/**
* Handler for API errors.
*
* @param {Object} request - Request info data.
* @param {Response} response - A consumed fetch `Response` object.
* @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) {
let errorCode = response.status in errors ? response.status : undefined
if (typeof errorData === 'string') {
// FIXME API: Patching errors that are plain text or html.
errorData = { error: errorData }
}
if ('log_ref' in errorData) {
// Define a special error so it won't get caught as a `APIBadRequestError`.
errorCode = 'log'
}
// This error can be catched by a view otherwise it will be catched by the `onUnhandledAPIError` handler.
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) {
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) => {
const error = e.reason
if (error instanceof APIError) {
onUnhandledAPIError(error)
// Seems like there's a bug in Firefox and the error logging in not prevented.
e.preventDefault()
}
})
// Keeping this in case it is needed.
// Global catching of errors occuring inside vue components.
// Vue.config.errorHandler = (err, vm, info) => {}
// Global catching of regular js errors.
// window.onerror = (message, source, lineno, colno, error) => {}
}

99
app/src/api/handlers.ts Normal file
View file

@ -0,0 +1,99 @@
/**
* API handlers.
* @module api/handlers
*/
import errors from '@/api/errors'
import { useInfos } from '@/composables/useInfos'
import type { APIRequest, APIRequestAction } from '@/composables/useRequests'
import { toEntries } from '@/helpers/commons'
import { STATUS_VARIANT, isOkStatus } from '@/helpers/yunohostArguments'
import type { StateStatus, Obj } from '@/types/commons'
import type { APIErrorData } from './api'
/**
* Try to get response content as json and if it's not as text.
*
* @param response - A fetch `Response` object.
* @returns Parsed response's json or response's text.
*/
export async function getResponseData<T extends any = any>(
response: Response,
): Promise<T> {
// FIXME the api should always return json as response
const responseText = await response.text()
try {
return JSON.parse(responseText)
} catch {
return responseText as T
}
}
/**
* 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
* we have to open it for every calls.
* Messages are dispatch to the store so it can handle them.
*
* @param request - Request info data.
* @returns Promise that resolve on websocket 'open' or 'error' event.
*/
export function openWebSocket(request: APIRequestAction): Promise<Event> {
const { host } = useInfos()
return new Promise((resolve) => {
const ws = new WebSocket(`wss://${host.value}/yunohost/api/messages`)
ws.onmessage = ({ data }) => {
const messages: Record<StateStatus, string> = JSON.parse(data)
toEntries(messages).forEach(([status, text]) => {
text = text.replaceAll('\n', '<br>')
const progressBar = text.match(/^\[#*\+*\.*\] > /)?.[0]
if (progressBar) {
text = text.replace(progressBar, '')
const progress: Obj<number> = { '#': 0, '+': 0, '.': 0 }
for (const char of progressBar) {
if (char in progress) progress[char] += 1
}
request.action.progress = Object.values(progress)
}
request.action.messages.push({
text,
variant: STATUS_VARIANT[status],
})
if (!isOkStatus(status)) request.action[`${status}s`]++
})
}
// ws.onclose = (e) => {}
ws.onopen = resolve
// Resolve also on error so the actual fetch may be called.
ws.onerror = resolve
})
}
/**
* Handler for API errors.
*
* @param request - Request info data.
* @param response - A consumed fetch `Response` object.
* @param errorData - The response parsed json/text.
* @returns an `APIError` or subclass with request and response data.
*/
export function getError(
request: APIRequest,
response: Response,
errorData: string | APIErrorData,
) {
let errorCode = (
response.status in errors ? response.status : 'default'
) as keyof typeof errors
if (typeof errorData === 'string') {
// FIXME API: Patching errors that are plain text or html.
errorData = { error: errorData }
}
if ('log_ref' in errorData) {
// Define a special error so it won't get caught as a `APIBadRequestError`.
errorCode = 'log'
}
// This error can be catched by a view otherwise it will be catched by the global error handler.
return new errors[errorCode](request, response, errorData)
}

View file

@ -1,2 +0,0 @@
export { default, objectToParams } from './api'
export { handleError, registerGlobalErrorHandlers } from './handlers'

2
app/src/api/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { default, objectToParams } from './api'
export { getError } from './handlers'

View file

@ -1,75 +0,0 @@
<template>
<BInputGroup v-bind="$attrs">
<InputItem
:id="id"
:value="value.localPart"
:placeholder="placeholder"
:state="state"
:aria-describedby="id + 'local-part-desc'"
@input="onInput('localPart', $event)"
@blur="$parent.$emit('touch')"
/>
<BInputGroupAppend>
<BInputGroupText>{{ value.separator }}</BInputGroupText>
</BInputGroupAppend>
<BInputGroupAppend>
<SelectItem
:value="value.domain"
:choices="choices"
:aria-describedby="id + 'domain-desc'"
@input="onInput('domain', $event)"
@blur="$parent.$emit('touch')"
/>
</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"
/>
</BInputGroup>
</template>
<script>
export default {
name: 'AdressInputSelect',
inheritAttrs: false,
props: {
// `value` is actually passed thru the `v-model` directive
value: { type: Object, required: true },
choices: { type: Array, required: true },
placeholder: { type: String, default: null },
id: { type: String, default: null },
state: { type: null, default: null },
type: { type: String, default: 'email' },
},
methods: {
onInput(key, value) {
this.$emit('input', {
...this.value,
[key]: value,
})
},
},
}
</script>
<style lang="scss" scoped>
.input-group-append ~ .input-group-append {
flex-basis: 40%;
}
select {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View file

@ -1,5 +1,36 @@
<script setup lang="ts">
import type { ColorVariant } from 'bootstrap-vue-next'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
id: string
title: string
variant?: ColorVariant
visible?: boolean
flush?: boolean
}>(),
{
variant: 'light',
visible: false,
flush: false,
},
)
const class_ = computed(() => {
const baseClass = 'card-collapse'
return [
baseClass,
{
[`${baseClass}-flush`]: props.flush,
[`${baseClass}-${props.variant}`]: props.variant,
},
]
})
</script>
<template>
<BCard v-bind="$attrs" no-body :class="_class">
<BCard no-body :class="class_">
<template #header>
<slot name="header">
<h2>
@ -9,7 +40,7 @@
class="card-collapse-button"
>
{{ title }}
<YIcon class="ml-auto" iname="chevron-right" />
<YIcon class="ms-auto" iname="chevron-right" />
</BButton>
</h2>
</slot>
@ -21,36 +52,9 @@
</BCard>
</template>
<script>
export default {
name: 'CardCollapse',
props: {
id: { type: String, required: true },
title: { type: String, required: true },
variant: { type: String, default: 'white' },
visible: { type: Boolean, default: false },
flush: { type: Boolean, default: false },
},
computed: {
_class() {
const baseClass = 'card-collapse'
return [
baseClass,
{
[`${baseClass}-flush`]: this.flush,
[`${baseClass}-${this.variant}`]: this.variant,
},
]
},
},
}
</script>
<style lang="scss" scoped>
.card-collapse {
.card-header {
:deep(.card-header) {
padding: 0;
}
@ -78,7 +82,7 @@ export default {
@each $color, $value in $theme-colors {
&-#{$color} {
background-color: $value;
color: color-yiq($value);
color: color-contrast($value);
}
}
}

View file

@ -1,86 +1,94 @@
<script>
<script setup lang="ts">
import { BCardGroup } from 'bootstrap-vue-next'
import {
h,
nextTick,
onBeforeUnmount,
onBeforeUpdate,
onMounted,
ref,
} from 'vue'
// Implementation of the feed pattern
// https://www.w3.org/WAI/ARIA/apg/patterns/feed/
export default {
name: 'CardDeckFeed',
const props = withDefaults(defineProps<{ stacks?: number }>(), { stacks: 21 })
const slots = defineSlots<{
default: any
}>()
props: {
stacks: { type: Number, default: 21 },
},
const busy = ref(false)
const range = ref(props.stacks)
const childrenCount = ref(slots.default()[0].children.length)
const feedElem = ref<InstanceType<typeof BCardGroup> | null>(null)
data() {
return {
busy: false,
range: this.stacks,
childrenCount: this.$slots.default.length,
}
},
methods: {
getTopParent(prev) {
return prev.parentElement === this.$refs.feed
? prev
: this.getTopParent(prev.parentElement)
},
onScroll() {
const elem = this.$refs.feed
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(() => {
this.busy = false
})
}
},
onKeydown(e) {
if (['PageUp', 'PageDown'].includes(e.code)) {
e.preventDefault()
const key = e.code === 'PageUp' ? 'previous' : 'next'
const sibling = this.getTopParent(e.target)[`${key}ElementSibling`]
if (sibling) {
sibling.focus()
sibling.scrollIntoView({ block: 'center' })
}
}
// FIXME Add `Home` and `End` shorcuts
},
},
mounted() {
window.addEventListener('scroll', this.onScroll)
this.$refs.feed.addEventListener('keydown', this.onKeydown)
this.onScroll()
},
beforeUpdate() {
const slots = this.$slots.default
if (this.childrenCount !== slots.length) {
this.range = this.stacks
this.childrenCount = slots.length
}
},
render(h) {
return h(
'BCardGroup',
{
attrs: { role: 'feed', 'aria-busy': this.busy.toString() },
props: { deck: true },
ref: 'feed',
},
this.$slots.default.slice(0, this.range),
)
},
beforeDestroy() {
window.removeEventListener('scroll', this.onScroll)
this.$refs.feed.removeEventListener('keydown', this.onKeydown)
},
function getTopParent(prev: HTMLElement): HTMLElement {
return prev.parentElement === feedElem.value?.$el
? prev
: getTopParent(prev.parentElement!)
}
function onScroll() {
const elem = feedElem.value?.$el
if (
window.innerHeight >
elem.clientHeight + elem.getBoundingClientRect().top - 200
) {
busy.value = true
range.value = Math.min(range.value + props.stacks, childrenCount.value)
nextTick().then(() => {
busy.value = false
})
}
}
function onKeydown(e: KeyboardEvent) {
if (['PageUp', 'PageDown'].includes(e.code)) {
e.preventDefault()
const key = e.code === 'PageUp' ? 'previous' : 'next'
const sibling = getTopParent(e.target as HTMLElement)[
`${key}ElementSibling`
] as HTMLElement | null
sibling?.focus()
sibling?.scrollIntoView({ block: 'center' })
}
// FIXME Add `Home` and `End` shorcuts
}
onMounted(() => {
window.addEventListener('scroll', onScroll)
feedElem.value?.$el.addEventListener('keydown', onKeydown)
onScroll()
})
onBeforeUpdate(() => {
const children = slots.default()[0].children
if (childrenCount.value !== children.length) {
range.value = props.stacks
childrenCount.value = children.length
}
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll)
feedElem.value?.$el.removeEventListener('keydown', onKeydown)
})
const root = () =>
h(
BCardGroup,
{
deck: true,
role: 'feed',
'aria-busy': busy.value,
ref: feedElem,
},
{
default: () => slots.default()[0].children.slice(0, range.value),
},
)
</script>
<template>
<root />
</template>

View file

@ -1,111 +0,0 @@
<template>
<AbstractForm
v-if="panel"
v-bind="{
id: panel.id + '-form',
validation,
serverError: panel.serverError,
}"
@submit.prevent.stop="onApply"
:no-footer="!panel.hasApplyButton"
>
<slot name="tab-top" />
<template v-if="panel.help" #disclaimer>
<div class="alert alert-info" v-html="help" />
</template>
<slot name="tab-before" />
<template v-for="section in panel.sections">
<Component
v-if="section.visible"
:is="section.name ? 'section' : 'div'"
:key="section.id"
class="panel-section"
>
<BCardTitle v-if="section.name" title-tag="h3">
{{ 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"
@action.stop="onAction(section.id, fname, section.fields)"
/>
<!-- eslint-enable -->
</template>
</Component>
</template>
<slot name="tab-after" />
</AbstractForm>
</template>
<script>
import { filterObject } from '@/helpers/commons'
export default {
name: 'ConfigPanel',
props: {
tabId: { type: String, required: true },
panels: { type: Array, default: undefined },
forms: { type: Object, default: undefined },
v: { type: Object, default: undefined },
},
computed: {
panel() {
return this.panels.find((panel) => panel.id === this.tabId)
},
validation() {
return this.v.forms[this.panel.id]
},
},
methods: {
onApply() {
const panelId = this.panel.id
this.$emit('submit', {
id: panelId,
form: this.forms[panelId],
})
},
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),
),
action: [panelId, sectionId, actionId].join('.'),
name: actionId,
})
},
},
}
</script>
<style lang="scss" scoped>
.card-title {
margin-bottom: 1em;
border-bottom: solid $border-width $gray-500;
}
::v-deep .panel-section:not(:last-child) {
margin-bottom: 3rem;
}
</style>

View file

@ -1,72 +1,88 @@
<script
setup
lang="ts"
generic="NestedMV extends Obj, MV extends Obj<NestedMV>"
>
import { useRoute } from 'vue-router'
import type { FormValidation } from '@/composables/form'
import type { KeyOfStr, Obj } from '@/types/commons'
import type { ConfigPanel, ConfigPanels } from '@/types/configPanels'
defineOptions({
inheritAttrs: false,
})
const currentRoute = useRoute()
const props = defineProps<{
panel: ConfigPanel<NestedMV, MV>
routes: ConfigPanels<NestedMV, MV>['routes']
validations: FormValidation<NestedMV>
}>()
const emit = defineEmits<{
apply: [action?: KeyOfStr<typeof props.panel.fields>]
}>()
const slots = defineSlots<{
'tab-top'?: any
'tab-before'?: any
default?: any
'tab-after'?: any
}>()
const modelValue = defineModel<NestedMV>({ required: true })
</script>
<template>
<div class="config-panel">
<RoutableTabs
v-if="routes_.length > 1"
:routes="routes_"
v-bind="{ panels, forms, v: $v, ...$attrs }"
v-on="$listeners"
<BCard v-if="routes.length > 1" no-body class="config-panel">
<BCardHeader tag="nav">
<BNav card-header fill pills>
<BNavItem
v-for="route in routes"
:key="route.text"
:to="route.to"
:active="currentRoute.params.tabId === route.to.params?.tabId"
>
<!-- FIXME added :active="" because `exact-active-class` not working https://github.com/bootstrap-vue-next/bootstrap-vue-next/issues/1754 -->
<!-- exact-active-class="active" -->
<YIcon v-if="route.icon" :iname="route.icon" />
{{ route.text }}
</BNavItem>
</BNav>
</BCardHeader>
<CardForm
v-model="modelValue"
:fields="panel.fields"
:no-footer="!panel.hasApplyButton"
:sections="panel.sections"
:validations="validations"
as-tab
@submit="emit('apply')"
@action="emit('apply', $event)"
>
<template #tab-top>
<template #top>
<slot name="tab-top" />
</template>
<template #tab-before>
<template v-if="panel.help" #disclaimer>
<div class="alert alert-info" v-html="panel.help" />
</template>
<template #before-form>
<slot name="tab-before" />
</template>
<template #tab-after>
<template v-if="slots.default" #default>
<slot name="default" />
</template>
<template #after-form>
<slot name="tab-after" />
</template>
</RoutableTabs>
<YCard v-else :title="routes_[0].text" :icon="routes_[0].icon">
<slot name="tab-top" />
<slot name="tab-before" />
<slot name="tab-after" />
</YCard>
</div>
</CardForm>
</BCard>
<YCard v-else :title="routes[0].text" :icon="routes[0].icon">
<slot name="tab-top" />
<slot name="tab-before" />
<slot name="default" />
<slot name="tab-after" />
</YCard>
</template>
<script>
import { validationMixin } from 'vuelidate'
export default {
name: 'ConfigPanels',
inheritAttrs: false,
components: {
RoutableTabs: () => import('@/components/RoutableTabs.vue'),
},
mixins: [validationMixin],
props: {
panels: { type: Array, default: undefined },
forms: { type: Object, default: undefined },
validations: { type: Object, default: undefined },
errors: { type: Object, default: undefined }, // never used
routes: { type: Array, default: null },
noRedirect: { type: Boolean, default: false },
},
computed: {
routes_() {
if (this.routes) return this.routes
return this.panels.map((panel) => ({
to: { params: { tabId: panel.id } },
text: panel.name,
icon: panel.icon || 'wrench',
}))
},
},
validations() {
return { forms: this.validations }
},
created() {
if (!this.noRedirect && !this.$route.params.tabId) {
this.$router.replace({ params: { tabId: this.panels[0].id } })
}
},
}
</script>

View file

@ -1,76 +1,88 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = withDefaults(
defineProps<{
unrender?: boolean
minHeight?: number
renderDelay?: number
unrenderDelay?: number
rootMargin?: string
}>(),
{
unrender: true,
minHeight: 0,
renderDelay: 100,
unrenderDelay: 2000,
rootMargin: '300px',
},
)
defineSlots<{
default: any
}>()
const observer = ref<IntersectionObserver | null>(null)
const render = ref(false)
const fixedMinHeight = ref(props.minHeight)
const rootElem = ref<HTMLDivElement | null>(null)
onMounted(() => {
let unrenderTimer: number
let renderTimer: number
observer.value = 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 && rootElem.value!.offsetTop < window.innerHeight) {
intersecting = true
}
if (intersecting) {
clearTimeout(unrenderTimer)
// Show the component after a delay (to avoid rendering while scrolling fast)
renderTimer = window.setTimeout(
() => {
render.value = true
},
props.unrender ? props.renderDelay : 0,
)
if (!props.unrender) {
// Stop listening to intersections after first appearance if unrendering is not activated
observer.value!.disconnect()
}
} else if (props.unrender) {
clearTimeout(renderTimer)
// Hide the component after a delay if it's no longer in the viewport
unrenderTimer = window.setTimeout(() => {
fixedMinHeight.value = rootElem.value!.clientHeight
render.value = false
}, props.unrenderDelay)
}
},
{ rootMargin: props.rootMargin },
)
observer.value.observe(rootElem.value!)
})
onBeforeUnmount(() => {
observer.value!.disconnect()
})
</script>
<template>
<div class="lazy-renderer" :style="`min-height: ${fixedMinHeight}px`">
<div
ref="rootElem"
class="lazy-renderer"
:style="`min-height: ${fixedMinHeight}px`"
>
<slot v-if="render" />
</div>
</template>
<script>
export default {
name: 'LazyRenderer',
props: {
unrender: { type: Boolean, default: true },
minHeight: { type: Number, default: 0 },
renderDelay: { type: Number, default: 100 },
unrenderDelay: { type: Number, default: 2000 },
rootMargin: { type: String, default: '300px' },
},
data() {
return {
observer: null,
render: false,
fixedMinHeight: this.minHeight,
}
},
mounted() {
let unrenderTimer
let renderTimer
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()
}
} 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() {
this.observer.disconnect()
},
}
</script>

View file

@ -1,20 +1,73 @@
<script setup lang="ts">
import { watchThrottled } from '@vueuse/core'
import type { BListGroup } from 'bootstrap-vue-next'
import { nextTick, ref } from 'vue'
import type { RequestMessage } from '@/composables/useRequests'
const props = withDefaults(
defineProps<{
messages: RequestMessage[]
fixedHeight?: boolean
bordered?: boolean
autoScroll?: boolean
limit?: number
}>(),
{
fixedHeight: false,
bordered: false,
autoScroll: false,
limit: undefined,
},
)
const rootElem = ref<InstanceType<typeof BListGroup> | null>(null)
const auto = ref(props.autoScroll)
const reducedMessages = ref<RequestMessage[]>([...props.messages])
watchThrottled(
() => props.messages,
(messages) => {
const len = messages.length
if (!props.limit || len <= props.limit) {
reducedMessages.value = [...messages]
} else {
reducedMessages.value = messages.slice(len - props.limit)
}
if (auto.value) nextTick(scrollToEnd)
},
{ throttle: 300, deep: true },
)
function scrollToEnd() {
const elem = rootElem.value?.$el
elem?.scrollTo(0, elem.lastElementChild.offsetTop)
}
function onScroll() {
if (!props.autoScroll) return
const elem = rootElem.value!.$el
const { scrollHeight, scrollTop, clientHeight } = elem
auto.value = scrollHeight === scrollTop + clientHeight
}
</script>
<template>
<BListGroup
v-bind="$attrs"
ref="rootElem"
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"
/>
<YListGroupItem
v-for="({ color, text }, i) in reducedMessages"
v-for="({ variant, text }, i) in reducedMessages"
:key="i"
:variant="color"
:variant="variant"
size="xs"
>
<span v-html="text" />
@ -22,55 +75,6 @@
</BListGroup>
</template>
<script>
export default {
name: 'MessageListGroup',
props: {
messages: { type: Array, required: true },
fixedHeight: { type: Boolean, default: false },
bordered: { type: Boolean, default: false },
autoScroll: { type: Boolean, default: false },
limit: { type: Number, default: null },
},
data() {
return {
auto: true,
}
},
computed: {
reducedMessages() {
const len = this.messages.length
if (!this.limit || len <= this.limit) {
return this.messages
}
return this.messages.slice(len - this.limit)
},
},
methods: {
scrollToEnd() {
if (!this.auto) return
this.$nextTick(() => {
this.$el.scrollTo(0, this.$el.lastElementChild.offsetTop)
})
},
onScroll({ target }) {
this.auto = target.scrollHeight === target.scrollTop + target.clientHeight
},
},
created() {
if (this.autoScroll) {
this.$watch('messages', this.scrollToEnd)
}
},
}
</script>
<style lang="scss" scoped>
.fixed-height {
max-height: 20vh;

View file

@ -1,104 +1,88 @@
<script setup lang="ts">
import { computed, toRefs } from 'vue'
import type { APIRequest } from '@/composables/useRequests'
import { STATUS_VARIANT } from '@/helpers/yunohostArguments'
const props = defineProps<{
request: APIRequest
type: 'overlay' | 'history'
}>()
const emit = defineEmits<{ showError: [id: string] }>()
const statusVariant = computed(() => STATUS_VARIANT[props.request.status])
const { errors, warnings } = toRefs(
props.request.action || { errors: 0, warnings: 0 },
)
const hour = computed(() => {
return new Date(props.request.date).toLocaleTimeString()
})
</script>
<template>
<div class="query-header w-100" v-on="$listeners" v-bind="$attrs">
<!-- STATUS -->
<div class="query-header d-flex align-items-center w-100">
<span
class="status"
:class="['bg-' + color, statusSize]"
:aria-label="$t('api.query_status.' + request.status)"
:class="[`bg-${statusVariant}`, type]"
:aria-label="$t(`api.query_status.${request.status}`)"
/>
<!-- REQUEST DESCRIPTION -->
<strong class="request-desc">
<!-- tabindex 0 on title for focus-trap when no tabable elements -->
<strong :tabindex="type === 'overlay' ? 0 : undefined">
{{ request.humanRoute }}
</strong>
<div v-if="request.errors || request.warnings">
<!-- WEBSOCKET ERRORS COUNT -->
<span class="count" v-if="request.errors">
{{ request.errors }}<YIcon iname="bug" class="text-danger ml-1" />
<div v-if="errors || warnings">
<span v-if="errors" class="ms-2">
{{ errors }}<YIcon iname="bug" class="text-danger ms-1" />
</span>
<!-- WEBSOCKET WARNINGS COUNT -->
<span class="count" v-if="request.warnings">
{{ request.warnings
}}<YIcon iname="warning" class="text-warning ml-1" />
<span v-if="warnings" class="ms-2">
{{ warnings }}<YIcon iname="warning" class="text-warning ms-1" />
</span>
</div>
<!-- VIEW ERROR BUTTON -->
<BButton
v-if="showError && request.error"
size="sm"
pill
class="error-btn ml-auto py-0"
variant="danger"
@click="reviewError"
>
<small v-t="'api_error.view_error'" />
</BButton>
<template v-if="type === 'history'">
<BButton
v-if="request.err"
size="sm"
pill
class="error-btn ms-auto py-0"
variant="danger"
@click.stop="emit('showError', request.id)"
>
<small v-t="'api_error.view_error'" />
</BButton>
<!-- TIME DISPLAY -->
<time
v-if="showTime"
:datetime="hour(request.date)"
:class="request.error ? 'ml-2' : 'ml-auto'"
>
{{ hour(request.date) }}
</time>
<time :datetime="hour" :class="request.err ? 'ms-2' : 'ms-auto'">
{{ hour }}
</time>
</template>
</div>
</template>
<script>
export default {
name: 'QueryHeader',
props: {
request: { type: Object, required: true },
statusSize: { type: String, default: '' },
showTime: { type: Boolean, default: false },
showError: { type: Boolean, default: false },
},
computed: {
color() {
const statuses = {
pending: 'primary',
success: 'success',
warning: 'warning',
error: 'danger',
}
return statuses[this.request.status]
},
errorsCount() {
return this.request.messages.filter(({ type }) => type === 'danger')
.length
},
warningsCount() {
return this.request.messages.filter(({ type }) => type === 'warning')
.length
},
},
methods: {
reviewError() {
this.$store.dispatch('REVIEW_ERROR', this.request)
},
hour(date) {
return new Date(date).toLocaleTimeString()
},
},
}
</script>
<style lang="scss" scoped>
div {
display: flex;
align-items: center;
.query-header {
font-size: $font-size-sm;
}
.status {
display: inline-block;
border-radius: 50%;
&.history {
width: 0.75rem;
height: 0.75rem;
margin-right: 0.5rem;
}
&.overlay {
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
}
}
.error-btn {
height: 1.25rem;
display: flex;
@ -107,35 +91,8 @@ div {
min-width: 70px;
}
.status {
display: inline-block;
border-radius: 50%;
width: 0.75rem;
min-width: 0.75rem;
height: 0.75rem;
margin-right: 0.25rem;
&.lg {
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
}
}
time {
min-width: 3.5rem;
min-width: 3rem;
text-align: right;
}
.count {
display: flex;
align-items: center;
margin-left: 0.5rem;
}
@include media-breakpoint-down(xs) {
.xs-hide .request-desc {
display: none;
}
}
</style>

View file

@ -1,34 +1,70 @@
<script setup lang="ts">
import type { TreeChildNode, AnyTreeNode } from '@/helpers/data/tree'
const props = withDefaults(
defineProps<{
tree: AnyTreeNode
flush?: boolean
last?: boolean
toggleText?: string
}>(),
{
flush: false,
last: undefined,
toggleText: undefined,
},
)
type NodeSlot = {
[K in keyof TreeChildNode as TreeChildNode[K] extends Function
? never
: K]: TreeChildNode[K]
}
defineSlots<{
default: (props: NodeSlot) => any
}>()
function getClasses(node: AnyTreeNode, i: number) {
const children = node.height > 0
const opened = children && node.data?.opened
const last =
props.last !== false &&
(!children || !opened) &&
i === props.tree.children.length - 1
return { collapsible: children, uncollapsible: !children, opened, last }
}
</script>
<template>
<BListGroup :flush="flush" :style="{ '--depth': tree.depth }">
<template v-for="(node, i) in tree.children">
<template v-for="(node, i) in tree.children" :key="node.id">
<BListGroupItem
:key="node.id"
class="list-group-item-action"
:class="getClasses(node, i)"
@click="$router.push(node.data.to)"
>
<slot name="default" v-bind="node" />
<slot name="default" v-bind="node as NodeSlot" />
<BButton
v-if="node.children"
v-if="node.height > 0"
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"
class="ms-2"
@click.stop="node.data.opened = !node.data.opened"
>
<span class="sr-only">{{ toggleText }}</span>
<span class="visually-hidden">{{ toggleText }}</span>
<YIcon iname="chevron-right" />
</BButton>
</BListGroupItem>
<BCollapse
v-if="node.children"
:key="'collapse-' + node.id"
v-model="node.data.opened"
v-if="node.height > 0"
:id="'collapse-' + node.id"
v-model="node.data.opened"
>
<RecursiveListGroup
:tree="node"
@ -36,7 +72,7 @@
flush
>
<!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT -->
<template slot="default" slot-scope="scope">
<template #default="scope">
<slot name="default" v-bind="scope" />
</template>
</RecursiveListGroup>
@ -45,31 +81,6 @@
</BListGroup>
</template>
<script>
export default {
name: 'RecursiveListGroup',
props: {
tree: { type: Object, required: true },
flush: { type: Boolean, default: false },
last: { type: Boolean, default: undefined },
toggleText: { type: String, default: null },
},
methods: {
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
return { collapsible: children, uncollapsible: !children, opened, last }
},
},
}
</script>
<style lang="scss" scoped>
.list-group {
.collapse {
@ -114,8 +125,13 @@ export default {
text-decoration: none;
background-color: $list-group-hover-bg;
@include hover-focus() {
background-color: darken($list-group-hover-bg, 3%);
&:hover,
&:focus {
background-color: shade-color($body-tertiary-bg, 3%);
[data-bs-theme='dark'] & {
background-color: tint-color($body-tertiary-bg-dark, 3%);
}
}
}
}

View file

@ -1,45 +0,0 @@
<template>
<BCard no-body>
<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"
>
<YIcon v-if="route.icon" :iname="route.icon" />
{{ route.text }}
</BNavItem>
</BNav>
</BCardHeader>
<!-- Bind extra props to the child view and forward child events to parent -->
<RouterView v-bind="$attrs" v-on="$listeners">
<template #tab-top>
<slot name="tab-top" />
</template>
<template #tab-before>
<slot name="tab-before" />
</template>
<template #tab-after>
<slot name="tab-after" />
</template>
</RouterView>
</BCard>
</template>
<script>
export default {
name: 'RoutableTabs',
// Thanks to `v-bind="$attrs"` and `inheritAttrs: false`, this component can forward
// arbitrary attributes (props) directly to its children.
inheritAttrs: false,
props: {
routes: { type: Array, required: true },
},
}
</script>

View file

@ -1,79 +0,0 @@
<template>
<div>
<BCardBody>
<slot name="disclaimer" />
<BForm
: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">
<div v-html="errorFeedback" />
</BAlert>
</slot>
</BForm>
</BCardBody>
<BCardFooter v-if="!noFooter">
<slot name="footer">
<BButton type="submit" variant="success" :form="id">
{{ submitText || $t('save') }}
</BButton>
</slot>
</BCardFooter>
</div>
</template>
<script>
export default {
name: 'AbstractForm',
props: {
id: { type: String, default: 'ynh-form' },
submitText: { type: String, default: null },
validation: { type: Object, default: null },
serverError: { type: String, default: '' },
inline: { type: Boolean, default: false },
formClasses: { type: [Array, String, Object], default: null },
noFooter: { type: Boolean, default: false },
},
computed: {
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) {
const v = this.validation
if (v) {
v.$touch()
if (v.$pending || v.$invalid) return
}
this.$emit('submit', e)
},
},
}
</script>
<style lang="scss" scoped>
.card-footer {
display: flex;
justify-content: flex-end;
align-items: center;
& > *:not(:first-child) {
margin-left: 0.5rem;
}
}
</style>

View file

@ -1,74 +1,237 @@
<script setup lang="ts" generic="MV extends Obj, FFD extends FormFieldDict<MV>">
import { createReusableTemplate } from '@vueuse/core'
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FormValidation } from '@/composables/form'
import { toEntries } from '@/helpers/commons'
import type { KeyOfStr, Obj, VueClass } from '@/types/commons'
import type { ConfigSection } from '@/types/configPanels'
import type {
AnyDisplayComponents,
AnyWritableComponents,
BaseItemComputedProps,
ButtonItemProps,
FormFieldDict,
} from '@/types/form'
import { isDisplayComponent, isWritableComponent } from '@/types/form'
const props = withDefaults(
defineProps<{
id?: string
fields?: FFD
validations?: FormValidation<MV>
submitText?: string
inline?: boolean
formClasses?: VueClass
noFooter?: boolean
hr?: boolean
sections?: ConfigSection<MV, FFD>[]
}>(),
{
id: 'ynh-form',
fields: undefined,
validations: undefined,
submitText: undefined,
inline: false,
formClasses: undefined,
noFooter: false,
hr: false,
sections: undefined,
},
)
const emit = defineEmits<{
submit: [e: SubmitEvent]
action: [actionId: KeyOfStr<FFD>] //, sectionId?: ConfigSection<MV, FFD>['id']]
'update:modelValue': [modelValue: MV]
}>()
const slots = defineSlots<
{
top?: any
disclaimer?: any
'before-form'?: any
default?: any
'server-error'?: any
'after-form'?: any
buttons: any
} & {
[K in KeyOfStr<FFD> as `field:${K}`]?: (_: FFD[K]) => any
} & {
[K in KeyOfStr<FFD> as `component:${K}`]?: (
_: FFD[K]['component'] extends AnyWritableComponents
? FFD[K]['cProps'] & BaseItemComputedProps
: FFD[K]['component'] extends AnyDisplayComponents
? FFD[K]['cProps']
: never,
) => any
}
>()
const modelValue = defineModel<MV>()
const { t } = useI18n()
const globalErrorFeedback = computed(() => {
const v = props.validations
if (!v) return ''
const externalResults = toValue(v.global.$externalResults[0]?.$message)
return externalResults ?? (v.form.$error ? t('form_errors.invalid_form') : '')
})
const fields = computed(() => (props.fields ? toEntries(props.fields) : []))
const sections = computed(() => {
const { sections, fields } = props
if (!sections || !fields) return
return sections.map((section) => ({
...section,
fields: section.fields.map((id) => [id, fields[id]]) as {
[k in Extract<keyof FFD, string>]: [k, FFD[k]]
}[Extract<keyof FFD, string>][],
}))
})
function onModelUpdate(key: keyof MV, value: MV[keyof MV]) {
emit('update:modelValue', {
...modelValue.value!,
[key]: value,
})
}
const Fields = createReusableTemplate<{
fieldsProps: { [k in Extract<keyof FFD, string>]: [k, FFD[k]] }[Extract<
keyof FFD,
string
>][]
}>()
// presence of <!-- @vue-expect-error --> are for `yarn type-check`,
// don't know why custom component slots name doesn't pass
</script>
<template>
<YCard v-bind="$attrs" class="card-form">
<Fields.define v-slot="{ fieldsProps }">
<template v-for="[k, field] in fieldsProps" :key="k">
<template v-if="toValue(field.visible) ?? true">
<!-- @vue-expect-error -->
<slot
v-if="isWritableComponent<MV[typeof k]>(field)"
:name="`field:${k}`"
v-bind="field"
>
<FormField
v-if="!field.readonly"
v-bind="field"
:model-value="modelValue![k]"
:validation="props.validations?.form[k]"
@update:model-value="onModelUpdate(k, $event)"
>
<!-- @vue-expect-error -->
<template v-if="slots[`component:${k}`]" #default="childProps">
<!-- @vue-expect-error -->
<slot :name="`component:${k}`" v-bind="childProps" />
</template>
</FormField>
<FormFieldReadonly
v-else
v-bind="field"
:model-value="modelValue![k]"
/>
</slot>
<!-- @vue-expect-error -->
<slot
v-else-if="isDisplayComponent(field)"
:name="`component:${k}`"
v-bind="field.cProps"
>
<Component
:is="field.component"
v-if="field.component !== 'ButtonItem'"
v-bind="field.cProps"
/>
<ButtonItem
v-else
v-bind="field.cProps as ButtonItemProps"
@action="emit('action', $event as KeyOfStr<FFD>)"
/>
</slot>
<hr v-if="field.hr ?? hr" />
</template>
</template>
</Fields.define>
<YCard class="card-form" v-bind="$attrs">
<template #default>
<slot name="top" />
<slot name="disclaimer" />
<slot name="before-form" />
<BForm
:id="id"
:inline="inline"
:class="formClasses"
@submit.prevent="onSubmit"
novalidate
@submit.prevent.stop="emit('submit', $event as SubmitEvent)"
>
<slot name="default" />
<slot name="default">
<template v-if="sections">
<template v-for="section in sections" :key="section.id">
<Component
:is="section.name ? 'section' : 'div'"
v-if="toValue(section.visible)"
class="form-section"
>
<BCardTitle v-if="section.name" title-tag="h3">
{{ section.name }}
<small v-if="section.help">{{ section.help }}</small>
</BCardTitle>
<!-- @vue-ignore-next-line -->
<Fields.reuse :fields-props="section.fields" />
</Component>
</template>
</template>
<template v-else-if="fields">
<!-- @vue-ignore-next-line -->
<Fields.reuse :fields-props="fields" />
</template>
</slot>
<slot name="server-error">
<BAlert
<YAlert
v-if="globalErrorFeedback !== ''"
alert
variant="danger"
class="my-3"
icon="ban"
:show="errorFeedback !== ''"
>
<div v-html="errorFeedback" />
</BAlert>
<div v-html="globalErrorFeedback" />
</YAlert>
</slot>
</BForm>
<slot name="after-form" />
</template>
<template v-if="!noFooter" #buttons>
<slot name="buttons">
<BButton type="submit" variant="success" :form="id">
{{ submitText ? submitText : $t('save') }}
{{ submitText ?? $t('save') }}
</BButton>
</slot>
</template>
</YCard>
</template>
<script>
export default {
name: 'CardForm',
props: {
id: { type: String, default: 'ynh-form' },
submitText: { type: String, default: null },
validation: { type: Object, default: null },
serverError: { type: String, default: '' },
inline: { type: Boolean, default: false },
formClasses: { type: [Array, String, Object], default: null },
noFooter: { type: Boolean, default: false },
},
computed: {
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) {
const v = this.validation
if (v) {
v.$touch()
if (v.$pending || v.$invalid) return
}
this.$emit('submit', e)
},
},
<style lang="scss" scoped>
.card-title {
margin-bottom: 1em;
border-bottom: solid $border-width $gray-500;
}
</script>
<style lang="scss"></style>
.form-section:not(:last-child) {
margin-bottom: 3rem;
}
</style>

View file

@ -1,6 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Cols } from '@/types/commons'
const props = withDefaults(
defineProps<{
term?: string
details?: string
cols?: Cols
}>(),
{
term: undefined,
details: undefined,
cols: () => ({ md: 4, xl: 3 }),
},
)
const cols = computed<Cols>(() => ({
md: 4,
xl: 3,
...props.cols,
}))
</script>
<template>
<BRow no-gutters class="description-row">
<BCol v-bind="cols_">
<BCol v-bind="cols">
<slot name="term">
<strong>{{ term }}</strong>
</slot>
@ -14,24 +39,6 @@
</BRow>
</template>
<script>
export default {
name: 'DescriptionRow',
props: {
term: { type: String, default: null },
details: { type: String, default: null },
cols: { type: Object, default: () => ({ md: 4, xl: 3 }) },
},
computed: {
cols_() {
return Object.assign({ md: 4, xl: 3 }, this.cols)
},
},
}
</script>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
@ -42,7 +49,7 @@ export default {
}
}
@include media-breakpoint-down(sm) {
@include media-breakpoint-down(md) {
flex-direction: column;
&:not(:last-of-type) {

View file

@ -1,17 +1,41 @@
<script setup lang="ts">
import type { ColorVariant } from 'bootstrap-vue-next'
import { ref } from 'vue'
withDefaults(
defineProps<{
id: string
title: string
content: string
variant?: ColorVariant
}>(),
{
variant: 'info',
},
)
const open = ref(false)
</script>
<template>
<span class="explain-what">
<slot name="default" />
<span class="explain-what-popover-container">
<BButton :id="id" href="#" variant="light">
<BButton
variant="light"
@focus="open = true"
@blur="open = false"
@click="open = !open"
>
<YIcon iname="question" />
<span class="sr-only">
<span class="visually-hidden">
{{ $t('details_about', { subject: title }) }}
</span>
</BButton>
<!-- FIXME missing prop `trigger` in bvn https://github.com/bootstrap-vue-next/bootstrap-vue-next/issues/1275 and looks like `placement` doesn't work -->
<BPopover
placement="auto"
:target="id"
triggers="focus"
v-model="open"
placement="top"
custom-class="explain-what-popover"
:variant="variant"
:title="title"
@ -22,43 +46,36 @@
</span>
</template>
<script>
export default {
name: 'ExplainWhat',
props: {
id: { type: String, required: true },
title: { type: String, required: true },
content: { type: String, required: true },
variant: { type: String, default: 'info' },
},
computed: {
cols_() {
return Object.assign({ md: 4, xl: 3 }, this.cols)
},
},
}
</script>
<style lang="scss" scoped>
.explain-what {
line-height: 1.2;
.btn {
padding: 0;
margin-left: 0.1rem;
margin-left: 0.25rem;
border-radius: 50rem;
line-height: inherit;
font-size: inherit;
}
&-popover {
background-color: $white;
border-width: 2px;
:deep() {
.popover {
background-color: $gray-800;
color: $black;
border-width: 2px;
::v-deep .popover-body {
color: $dark;
[data-bs-theme='dark'] & {
background-color: $white;
color: $white;
}
.popover-body {
color: $white;
[data-bs-theme='dark'] & {
color: $black;
}
}
}
}
}

View file

@ -1,34 +1,191 @@
<script
setup
lang="ts"
generic="C extends AnyWritableComponents, MV extends any"
>
import { createReusableTemplate } from '@vueuse/core'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTouch } from '@/composables/form'
import { omit } from '@/helpers/commons'
import type {
AnyWritableComponents,
BaseItemComputedProps,
FormFieldProps,
ItemComponentToItemProps,
} from '@/types/form'
defineOptions({
name: 'FormField',
inheritAttrs: false,
})
const props = withDefaults(defineProps<FormFieldProps<C, MV>>(), {
append: undefined,
asInputGroup: false,
component: undefined,
cProps: undefined,
description: undefined,
descriptionVariant: undefined,
id: undefined,
label: undefined,
labelFor: undefined,
link: undefined,
prepend: undefined,
rules: undefined,
validation: undefined,
})
defineEmits<{
'update:modelValue': [value: MV]
}>()
const slots = defineSlots<{
default?: (
componentProps: ItemComponentToItemProps[C] & BaseItemComputedProps,
) => any
description?: any
}>()
const modelValue = defineModel<MV>()
const attrs = useAttrs()
const { t } = useI18n()
useTouch(() => props.validation)
const computedAttrs = computed(() => {
const attrs_ = { ...omit(attrs, ['hr', 'readonly', 'visible']) }
if (props.label) {
const defaultAttrs = {
'label-cols-md': 4,
'label-cols-lg': 3,
'label-class': ['fw-bold', 'py-0'],
}
if (!('label-cols' in attrs_)) {
let attr: keyof typeof defaultAttrs
for (attr in defaultAttrs) {
if (!(attr in attrs)) attrs_[attr] = defaultAttrs[attr]
}
} else if (!('label-class' in attrs)) {
attrs_['label-class'] = defaultAttrs['label-class']
}
}
if (props.asInputGroup) {
attrs_['label-class'] = [
...((attrs_['label-class'] as []) || []),
'visually-hidden',
]
}
return attrs_
})
const id = computed(() => {
if (props.id) return props.id
const childId = props.cProps?.id || props.labelFor
return childId ? `${childId}-field` : undefined
})
const error = computed(() => {
const v = props.validation
if (v && v.$anyDirty) {
return v.$errors.length ? { errors: v.$errors, $model: v.$model } : null
}
return null
})
const state = computed(() => {
// Need to set state as null if no error, else component turn green
return error.value ? false : null
})
const errorMessage = computed(() => {
if (!error.value) return ''
const { errors, $model } = error.value
// FIXME maybe handle translation in validators directly
// https://vuelidate-next.netlify.app/advanced_usage.html#i18n-support
return errors
.map((err) => {
if (err) {
if (err.$validator === '$externalResults') return err.$message
return t('form_errors.' + err.$validator, {
value: $model,
...err.$params,
})
}
})
.join('<br>')
})
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{
ariaDescribedby: string[]
}>()
</script>
<template>
<!-- v-bind="$attrs" allow to pass default attrs not specified in this component slots -->
<BFormGroup
v-bind="attrs"
:id="_id"
:label-for="$attrs['label-for'] || props.id"
:state="state"
@touch="touch"
>
<DefineTemplate v-slot="{ ariaDescribedby }">
<!-- Make field props and state available as scoped slot data -->
<slot v-bind="{ self: { ...props, state }, touch }">
<slot
v-bind="{
...(props.cProps ?? ({} as ItemComponentToItemProps[C])),
ariaDescribedby,
state,
validation,
}"
>
<!-- if no component was passed as slot, render a component from the props -->
<Component
:is="component"
v-bind="props"
v-on="$listeners"
:value="value"
v-bind="props.cProps"
:is="props.component"
v-model="modelValue"
:aria-describedby="ariaDescribedby"
:state="state"
:required="validation ? 'required' in validation : false"
:validation="validation"
/>
</slot>
</DefineTemplate>
<!-- FIXME better use `labelSrOnly` prop instead of class but it is currently bugged -->
<BFormGroup
v-bind="computedAttrs"
:id="id"
:label="label"
:label-for="labelFor || props.cProps?.id"
:state="state"
>
<template #default="{ ariaDescribedby }">
<BInputGroup v-if="asInputGroup || append || prepend" :append="append">
<BInputGroupText
v-if="asInputGroup || prepend"
:aria-hidden="asInputGroup"
>
{{ asInputGroup ? label : prepend }}
</BInputGroupText>
<ReuseTemplate v-bind="{ ariaDescribedby }" />
</BInputGroup>
<ReuseTemplate v-else v-bind="{ ariaDescribedby }" />
</template>
<template #invalid-feedback>
<span v-html="errorMessage" />
</template>
<template #description>
<template v-if="description || link || 'description' in slots" #description>
<!-- 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="'name' in link ? link.name : undefined"
:href="'href' in link ? link.href : undefined"
class="ms-auto"
>
{{ link.text }}
</BLink>
</div>
@ -36,7 +193,6 @@
<VueShowdown
v-if="description"
:markdown="description"
flavor="github"
:class="{
['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant,
}"
@ -48,97 +204,8 @@
</BFormGroup>
</template>
<script>
export default {
name: 'FormField',
inheritAttrs: false,
props: {
// Component props (other <form-group> related attrs are passed thanks to $attrs)
id: { type: String, default: null },
description: { type: String, default: null },
descriptionVariant: { type: String, default: null },
link: { type: Object, default: null },
// Rendered field component props
component: { type: String, default: 'InputItem' },
value: { type: null, default: null },
props: { type: Object, default: () => ({}) },
validation: { type: Object, default: null },
},
computed: {
_id() {
if (this.id) return this.id
const childId = this.props.id || this.$attrs['label-for']
return childId ? childId + '_group' : null
},
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'],
}
if (!('label-cols' in attrs)) {
for (const attr in defaultAttrs) {
if (!(attr in attrs)) attrs[attr] = defaultAttrs[attr]
}
} else if (!('label-class' in attrs)) {
attrs['label-class'] = defaultAttrs['label-class']
}
}
return attrs
},
state() {
// Need to set state as null if no error, else component turn green
if (this.validation) {
return this.validation.$anyError === true ? false : null
}
return null
},
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) {
if (this.validation) {
// For fields that have multiple elements
if (name) {
this.validation[name].$touch()
} else {
this.validation.$touch()
}
}
},
findError(params, obj, parent = obj) {
for (const key in params) {
if (!obj[key]) {
return [key, obj.$params[key]]
}
if (obj[key].$anyError) {
return this.findError(obj[key].$params, obj[key], parent)
}
}
},
},
}
</script>
<style lang="scss" scoped>
::v-deep .invalid-feedback code {
:deep(.invalid-feedback code) {
background-color: $gray-200;
}
</style>

View file

@ -0,0 +1,248 @@
<script
setup
lang="ts"
generic="C extends AnyWritableComponents, MV extends any[]"
>
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import { omit } from '@/helpers/commons'
import type { ArrInnerType } from '@/types/commons'
import type {
AnyWritableComponents,
BaseItemComputedProps,
FormField,
FormFieldProps,
ItemComponentToItemProps,
} from '@/types/form'
defineOptions({
name: 'FormField',
inheritAttrs: false,
})
const props = withDefaults(
defineProps<
FormFieldProps<C, MV> & {
defaultValue?: () => ArrInnerType<MV>
addBtnText?: string
}
>(),
{
append: undefined,
asInputGroup: false,
component: undefined,
cProps: undefined,
description: undefined,
descriptionVariant: undefined,
id: undefined,
label: undefined,
labelFor: undefined,
link: undefined,
prepend: undefined,
rules: undefined,
defaultValue: undefined,
addBtnText: undefined,
validation: undefined,
},
)
const emit = defineEmits<{
'update:modelValue': [modelValue: MV]
}>()
const slots = defineSlots<{
default?: (_: {
componentProps: ItemComponentToItemProps[C] & BaseItemComputedProps
index: number
}) => any
description?: () => any
}>()
const modelValue = defineModel<MV>()
const { t } = useI18n()
const attrs = useAttrs()
const computedAttrs = computed(() => {
const attrs_ = { ...omit(attrs, ['hr', 'readonly', 'visible']) }
if (props.label) {
const defaultAttrs = {
'label-cols-md': 4,
'label-cols-lg': 3,
'label-class': ['fw-bold', 'py-0'],
}
if (!('label-cols' in attrs_)) {
let attr: keyof typeof defaultAttrs
for (attr in defaultAttrs) {
if (!(attr in attrs_)) attrs_[attr] = defaultAttrs[attr]
}
} else if (!('label-class' in attrs_)) {
attrs_['label-class'] = defaultAttrs['label-class']
}
}
return attrs_
})
const id = computed(() => {
if (props.id) return props.id
return props.cProps?.id ? props.cProps?.id + '_group' : undefined
})
const error = computed(() => {
const v = props.validation
if (v && v.$dirty) {
return v.$errors.length ? { errors: v.$errors, $model: v.$model } : null
}
return null
})
const subProps = computed<FormFieldProps<C, ArrInnerType<MV>>[]>(() => {
return (
modelValue.value?.map((modelValue: ArrInnerType<MV>, i) => {
return {
cProps: {
...(props.cProps ?? ({} as ItemComponentToItemProps[C])),
id: `${props.cProps?.id}.${i}`,
},
validation: props.validation?.[i],
modelValue,
component: props.component,
}
}) || []
)
})
const state = computed(() => {
// Need to set state as null if no error, else component turn green
return error.value ? false : null
})
const errorMessage = computed(() => {
if (!error.value) return ''
const { errors, $model } = error.value
// FIXME maybe handle translation in validators directly
// https://vuelidate-next.netlify.app/advanced_usage.html#i18n-support
return errors
.map((err) => {
if (err) {
if (err.$validator === '$externalResults') return err.$message
return t('form_errors.' + err.$validator, {
value: $model,
...err.$params,
})
}
})
.join('<br>')
})
function addElement() {
const value = [...(modelValue.value || []), props.defaultValue!()] as MV
emit('update:modelValue', value)
// FIXME: Focus newly inserted form item
}
function removeElement(index: number) {
if (!modelValue.value) return
const value = [...modelValue.value] as MV
value.splice(index, 1)
emit('update:modelValue', value)
}
function updateElement(index: number, newValue: ArrInnerType<MV>) {
if (!modelValue.value) return
const value = [...modelValue.value] as MV
value.splice(index, 1, newValue)
emit('update:modelValue', value)
}
</script>
<template>
<BFormGroup v-bind="computedAttrs" :id="id" :label="label" :state="state">
<div v-for="(fieldProps, index) in subProps" :key="index" class="item">
<!-- @vue-expect-error -->
<FormField
v-bind="fieldProps"
class="w-100 mb-3"
@update:model-value="updateElement(index, $event as ArrInnerType<MV>)"
>
<template v-if="slots.default" #default="componentProps">
<!-- @vue-expect-error -->
<slot v-bind="{ componentProps, index }" />
</template>
</FormField>
<BButton
v-if="defaultValue !== undefined"
variant="danger"
@click="removeElement(index)"
>
<YIcon :title="$t('delete')" iname="trash-o" />
<span class="visually-hidden">{{ $t('delete') }}</span>
</BButton>
</div>
<BButton
v-if="defaultValue !== undefined"
variant="success"
@click="addElement()"
>
<YIcon iname="plus" /> {{ addBtnText ?? $t('add') }}
</BButton>
<!-- FIXME is it needed? or more generic error like "errors in this multiple fields" -->
<template #invalid-feedback>
<span v-html="errorMessage" />
</template>
<template #description>
<slot name="description">
<!-- Render description -->
<template v-if="description || link">
<div class="d-flex">
<BLink
v-if="link"
:to="'name' in link ? link.name : undefined"
:href="'href' in link ? link.href : undefined"
class="ms-auto"
>
{{ link.text }}
</BLink>
</div>
<VueShowdown
v-if="description"
:markdown="description"
:class="{
['alert p-1 px-2 alert-' + descriptionVariant]:
descriptionVariant,
}"
/>
</template>
</slot>
</template>
</BFormGroup>
</template>
<style lang="scss" scoped>
:deep(.invalid-feedback code) {
background-color: $gray-200;
}
.item {
display: flex;
justify-items: stretch;
.btn-danger {
align-self: flex-start;
margin-left: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,79 @@
<script
setup
lang="ts"
generic="C extends AnyWritableComponents, MV extends any"
>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Cols } from '@/types/commons'
import type {
AnyWritableComponents,
FormFieldReadonlyProps,
} from '@/types/form'
defineOptions({
name: 'FormFieldReadonly',
inheritAttrs: false,
})
const props = withDefaults(defineProps<FormFieldReadonlyProps<C>>(), {
id: undefined,
cols: () => ({ md: 4, lg: 3 }),
})
const modelValue = defineModel<MV>()
const { t } = useI18n()
const cols = computed<Cols>(() => ({
md: 4,
xl: 3,
...props.cols,
}))
const text = computed(() => {
return parseValue(modelValue.value)
})
function parseValue(value: any) {
const item = props.component
if (item === 'FileItem') value = value.file ? value.file.name : null
if (item === 'CheckboxItem') value = t(value ? 'yes' : 'no')
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
if (Array.isArray(value)) {
value = value.length ? value.join(t('words.separator')) : null
}
if ([null, undefined, ''].includes(value)) value = t('words.none')
return value
}
</script>
<template>
<BRow no-gutters class="description-row">
<BCol v-bind="cols" class="fw-bold">
{{ label }}
</BCol>
<BCol>
<!-- FIXME not sure about rendering html -->
<div v-html="text" />
</BCol>
</BRow>
</template>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
margin: 1rem 0;
}
@include media-breakpoint-down(md) {
flex-direction: column;
&:not(:last-of-type) {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: $border-width solid $card-border-color;
}
}
}
</style>

View file

@ -1,70 +0,0 @@
<template>
<BRow no-gutters class="description-row">
<BCol v-bind="cols_" class="font-weight-bold">
{{ label }}
</BCol>
<BCol>
<!-- FIXME not sure about rendering html -->
<div v-html="text" />
</BCol>
</BRow>
</template>
<script>
export default {
name: 'ReadOnlyField',
inheritAttrs: false,
props: {
label: { type: String, required: true },
component: { type: String, default: 'InputItem' },
value: { type: null, default: null },
cols: { type: Object, default: () => ({ md: 4, lg: 3 }) },
},
computed: {
cols_() {
return Object.assign({ md: 4, lg: 3 }, this.cols)
},
text() {
return this.parseValue(this.value)
},
},
methods: {
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
}
if ([null, undefined, ''].includes(this.value))
value = this.$i18n.t('words.none')
return value
},
},
}
</script>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
margin: 1rem 0;
}
@include media-breakpoint-down(sm) {
flex-direction: column;
&:not(:last-of-type) {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: $border-width solid $card-border-color;
}
}
}
</style>

View file

@ -1,50 +1,37 @@
<script setup lang="ts">
import type { CustomRoute } from '@/types/commons'
defineProps<{
label?: string
button?: CustomRoute
}>()
const slots = defineSlots<{
'group-left': any
'group-right': any
}>()
</script>
<template>
<BButtonToolbar :aria-label="label" id="top-bar">
<div id="top-bar-left" class="top-bar-group" v-if="hasLeftSlot">
<BButtonToolbar id="top-bar" :aria-label="label">
<div v-if="slots['group-left']" id="top-bar-left" class="top-bar-group">
<slot name="group-left" />
</div>
<div id="top-bar-right" class="top-bar-group" v-if="hasRightSlot || button">
<slot v-if="hasRightSlot" name="group-right" />
<div
v-if="slots['group-right'] || button"
id="top-bar-right"
class="top-bar-group"
>
<slot v-if="slots['group-right']" name="group-right" />
<BButton v-else variant="success" :to="button.to">
<BButton v-else-if="button" variant="success" :to="button.to">
<YIcon v-if="button.icon" :iname="button.icon" /> {{ button.text }}
</BButton>
</div>
</BButtonToolbar>
</template>
<script>
export default {
name: 'TopBar',
props: {
label: { type: String, default: null },
button: {
type: Object,
default: null,
validator(value) {
return ['text', 'to'].every((prop) => prop in value)
},
},
},
data() {
return {
hasLeftSlot: null,
hasRightSlot: null,
}
},
created() {
this.$nextTick(() => {
this.hasLeftSlot = 'group-left' in this.$slots
this.hasRightSlot = 'group-right' in this.$slots
})
},
}
</script>
<style lang="scss" scoped>
#top-bar {
margin-bottom: 1rem;
@ -55,19 +42,19 @@ export default {
margin-bottom: 1rem;
}
@include media-breakpoint-down(xs) {
@include media-breakpoint-down(sm) {
.top-bar-group {
flex-direction: column-reverse;
}
}
@include media-breakpoint-down(sm) {
@include media-breakpoint-down(md) {
flex-direction: column-reverse;
#top-bar-right {
margin-bottom: 0.75rem;
::v-deep > * {
:deep(> *) {
margin-bottom: 0.25rem;
}
}
@ -88,7 +75,7 @@ export default {
margin-left: auto;
}
::v-deep .btn {
:deep(.btn) {
margin-left: 0.5rem;
&.dropdown-toggle-split {
margin-left: 0;

View file

@ -1,85 +0,0 @@
<template>
<div>
<TopBar v-if="hasTopBar">
<template #group-left>
<slot name="top-bar-group-left" />
</template>
<template #group-right>
<slot name="top-bar-group-right" />
</template>
</TopBar>
<slot v-else name="top-bar" />
<slot name="top" v-bind="{ loading: isLoading }" />
<BSkeletonWrapper :loading="isLoading">
<template #loading>
<slot name="skeleton">
<Component :is="skeleton" />
</slot>
</template>
<!-- Empty div to be able to receive multiple components -->
<div>
<slot name="default" v-bind="{ loading: isLoading }" />
</div>
</BSkeletonWrapper>
<slot name="bot" v-bind="{ loading: isLoading }" />
</div>
</template>
<script>
import api from '@/api'
export default {
name: 'ViewBase',
props: {
queries: { type: Array, default: null },
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 },
},
data() {
return {
fallback_loading:
this.loading === null && this.queries !== null ? true : null,
}
},
computed: {
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,
)
},
},
methods: {
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
})
},
},
created() {
if (this.queries) this.fetchQueries()
},
}
</script>

View file

@ -1,76 +1,87 @@
<template>
<ViewBase v-bind="$attrs" v-on="$listeners" :skeleton="skeleton">
<template v-if="hasCustomTopBar" #top-bar>
<slot name="top-bar" />
</template>
<template v-if="!hasCustomTopBar" #top-bar-group-left>
<BInputGroup class="w-100">
<BInputGroupPrepend is-text>
<YIcon iname="search" />
</BInputGroupPrepend>
<script setup lang="ts" generic="T extends Obj | AnyTreeNode">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
<BFormInput
id="top-bar-search"
:value="search"
@input="$emit('update:search', $event)"
:placeholder="
$t('search.for', { items: $tc('items.' + itemsName, 2) })
"
:disabled="!items"
/>
</BInputGroup>
</template>
<template v-if="!hasCustomTopBar" #top-bar-group-right>
<slot name="top-bar-buttons" />
</template>
import type { AnyTreeNode } from '@/helpers/data/tree'
import type { Obj } from '@/types/commons'
<template #top>
<slot name="top" />
</template>
<template #default>
<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) },
)
}}
</slot>
</BAlert>
<slot v-else name="default" />
</template>
<template #bot>
<slot name="bot" />
</template>
<template #skeleton>
<slot name="skeleton" />
</template>
</ViewBase>
</template>
<script>
export default {
name: 'ViewSearch',
props: {
items: { type: null, required: true },
itemsName: { type: String, required: true },
filteredItems: { type: null, required: true },
search: { type: String, default: null },
skeleton: { type: String, default: 'ListGroupSkeleton' },
const props = withDefaults(
defineProps<{
items?: T[] | null
itemsName: string | null
}>(),
{
items: undefined,
},
)
computed: {
hasCustomTopBar() {
return 'top-bar' in this.$slots
},
},
}
const slots = defineSlots<{
'top-bar': any
'top-bar-buttons': any
top: any
'alert-message': any
'forced-default'?: any
default: any
bot: any
skeleton: any
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const model = defineModel<string>()
const { t } = useI18n()
const noItemsMessage = computed(() => {
if (props.items) return
return t(
props.items === undefined ? 'items_verbose_count' : 'search.not_found',
{ items: t('items.' + props.itemsName, 0) },
0,
)
})
</script>
<template>
<div>
<slot v-if="slots['top-bar']" name="top-bar" />
<TopBar v-else>
<template #group-left>
<BInputGroup class="w-100">
<BInputGroupText>
<YIcon iname="search" />
</BInputGroupText>
<BFormInput
id="top-bar-search"
v-model="model"
:placeholder="
t('search.for', { items: t('items.' + itemsName, 2) })
"
:disabled="items === undefined"
/>
</BInputGroup>
</template>
<template #group-right>
<slot name="top-bar-buttons" />
</template>
</TopBar>
<slot name="top" />
<slot name="forced-default">
<YAlert
v-if="noItemsMessage"
alert
icon="exclamation-triangle"
variant="warning"
>
{{ noItemsMessage }}
</YAlert>
<slot v-else name="default" />
</slot>
<slot name="bot" />
</div>
</template>

View file

@ -1,36 +1,45 @@
<script setup lang="ts">
import type { ColorVariant } from 'bootstrap-vue-next'
import { BAlert } from 'bootstrap-vue-next'
import { computed } from 'vue'
import { DEFAULT_VARIANT_ICON } from '@/helpers/yunohostArguments'
const props = withDefaults(
defineProps<{
alert?: boolean
icon?: string
variant?: ColorVariant
}>(),
{
alert: false,
icon: undefined,
variant: 'info' as const,
},
)
const icon = computed(() => {
return props.icon || DEFAULT_VARIANT_ICON[props.variant]
})
</script>
<template>
<Component
v-bind="$attrs"
:is="alert ? 'BAlert' : 'div'"
:variant="alert ? variant : null"
:is="alert ? BAlert : 'div'"
:model-value="alert ? true : undefined"
:variant="alert ? variant : undefined"
:class="{ ['alert alert-' + variant]: !alert }"
class="yuno-alert d-flex flex-column flex-md-row align-items-center"
>
<YIcon :iname="_icon" class="mr-md-3 mb-md-0 mb-2 md" />
<YIcon
v-if="icon"
:iname="icon"
:variant="variant"
class="me-md-3 mb-md-0 mb-2 md"
/>
<div class="w-100">
<slot name="default" />
</div>
</Component>
</template>
<script>
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
export default {
name: 'YAlert',
props: {
alert: { type: Boolean, default: false },
variant: { type: String, default: 'info' },
icon: { type: String, default: null },
},
computed: {
_icon() {
if (this.icon) return this.icon
return DEFAULT_STATUS_ICON[this.variant]
},
},
}
</script>

View file

@ -1,14 +1,23 @@
<script setup lang="ts">
import { useInfos } from '@/composables/useInfos'
const { breadcrumb, updateHtmlTitle } = useInfos()
// Call this here to trigger title update at page load (with translation)
updateHtmlTitle()
</script>
<template>
<BBreadcrumb v-if="breadcrumb.length">
<BBreadcrumbItem to="/">
<span class="sr-only">{{ $t('home') }}</span>
<span class="visually-hidden">{{ $t('home') }}</span>
<YIcon iname="home" />
</BBreadcrumbItem>
<BBreadcrumbItem
v-for="({ name, text }, i) in breadcrumb"
:key="name"
:to="{ name }"
v-for="({ to, text }, i) in breadcrumb"
:key="i"
:to="to"
:active="i === breadcrumb.length - 1"
>
{{ text }}
@ -16,18 +25,6 @@
</BBreadcrumb>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'YBreadcrumb',
computed: {
...mapGetters(['breadcrumb']),
},
}
</script>
<style lang="scss" scoped>
.breadcrumb {
border: none;

View file

@ -1,19 +1,62 @@
<script setup lang="ts">
import type { Breakpoint } from 'bootstrap-vue-next'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
id?: string
asTab?: boolean
noBody?: boolean
title?: string
titleTag?: string
icon?: string
collapsible?: boolean
collapsed?: boolean
buttonUnbreak?: Breakpoint
}>(),
{
id: 'ynh-form',
asTab: false,
noBody: false,
title: undefined,
titleTag: 'h2',
icon: undefined,
collapsible: false,
collapsed: false,
buttonUnbreak: 'md',
},
)
const slots = defineSlots<{
header: any
'header-next': any
'header-buttons': any
default: any
buttons: any
}>()
const visible = ref(!props.collapsed)
</script>
<template>
<BCard v-bind="$attrs" :no-body="collapsable ? true : $attrs['no-body']">
<template #header>
<BCard
:no-body="collapsible ? true : noBody"
:class="{ 'border-0': asTab, collapsible: collapsible }"
>
<template v-if="!asTab" #header>
<div class="w-100 d-flex align-items-center flex-wrap custom-header">
<slot name="header">
<Component :is="titleTag" class="custom-header-title">
<YIcon v-if="icon" :iname="icon" class="mr-2" />{{ title }}
<YIcon v-if="icon" :iname="icon" class="me-2" />{{ title }}
</Component>
<slot name="header-next" />
</slot>
<div
v-if="hasButtons"
v-if="slots['header-buttons']"
class="mt-2 w-100 custom-header-buttons"
:class="{
[`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
[`ms-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
buttonUnbreak,
}"
>
@ -22,24 +65,24 @@
</div>
<BButton
v-if="collapsable"
@click="visible = !visible"
v-if="collapsible"
size="sm"
variant="outline-secondary"
class="align-self-center ml-auto"
class="align-self-center ms-auto"
:class="{
'not-collapsed': visible,
collapsed: !visible,
[`ml-${buttonUnbreak}-2`]: buttonUnbreak,
[`ms-${buttonUnbreak}-2`]: buttonUnbreak,
}"
@click="visible = !visible"
>
<YIcon iname="chevron-right" />
<span class="sr-only">{{ $t('words.collapse') }}</span>
<span class="visually-hidden">{{ $t('words.collapse') }}</span>
</BButton>
</template>
<BCollapse v-if="collapsable" :visible="visible">
<slot v-if="'no-body' in $attrs" name="default" />
<BCollapse v-if="collapsible" :visible="visible">
<slot v-if="noBody" name="default" />
<BCardBody v-else>
<slot name="default" />
</BCardBody>
@ -48,42 +91,14 @@
<slot name="default" />
</template>
<template #footer v-if="'buttons' in $slots">
<template v-if="slots['buttons']" #footer>
<slot name="buttons" />
</template>
</BCard>
</template>
<script>
export default {
name: 'YCard',
props: {
id: { type: String, default: 'ynh-form' },
title: { type: String, default: null },
titleTag: { type: String, default: 'h2' },
icon: { type: String, default: null },
collapsable: { type: Boolean, default: false },
collapsed: { type: Boolean, default: false },
buttonUnbreak: { type: String, default: 'md' },
},
data() {
return {
visible: !this.collapsed,
}
},
computed: {
hasButtons() {
return 'header-buttons' in this.$slots
},
},
}
</script>
<style lang="scss" scoped>
.card-header {
:deep(.card-header) {
display: flex;
.custom-header {
@ -97,7 +112,7 @@ export default {
}
}
.card-footer {
:deep(.card-footer) {
display: flex;
justify-content: flex-end;
align-items: center;
@ -106,7 +121,7 @@ export default {
margin-left: 0.5rem;
}
}
.collapse:not(.show) + .card-footer {
:deep(.collapse:not(.show) + .card-footer) {
display: none;
}
</style>

View file

@ -1,3 +1,12 @@
<script setup lang="ts">
import type { ColorVariant } from 'bootstrap-vue-next'
defineProps<{
iname: string
variant?: ColorVariant
}>()
</script>
<template>
<span
:class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']"
@ -5,16 +14,6 @@
/>
</template>
<script>
export default {
name: 'YIcon',
props: {
iname: { type: String, required: true },
variant: { type: String, default: null },
},
}
</script>
<style lang="scss" scoped>
.icon {
font-size: inherit;
@ -48,7 +47,7 @@ export default {
@each $color, $value in $theme-colors {
&.#{$color} {
background-color: $value;
color: color-yiq($value);
color: color-contrast($value);
}
}
}

View file

@ -1,7 +1,46 @@
<script setup lang="ts">
import type { Breakpoint, ColorVariant } from 'bootstrap-vue-next'
import { computed } from 'vue'
import { DEFAULT_VARIANT_ICON } from '@/helpers/yunohostArguments'
const props = withDefaults(
defineProps<{
variant?: ColorVariant
icon?: string
noIcon?: boolean
noStatus?: boolean
size?: Breakpoint | 'xs'
faded?: boolean
}>(),
{
variant: 'light',
icon: undefined,
noIcon: false,
noStatus: false,
size: undefined,
faded: false,
},
)
const icon = computed(() => {
if (props.noIcon) return
return props.icon || DEFAULT_VARIANT_ICON[props.variant]
})
const class_ = computed(() => {
const baseClass = 'yuno-list-group-item-'
return [
baseClass + props.size,
baseClass + props.variant,
{ [baseClass + 'faded']: props.faded },
]
})
</script>
<template>
<BListGroupItem class="yuno-list-group-item" :class="_class" v-bind="$attrs">
<BListGroupItem v-bind="$attrs" class="yuno-list-group-item" :class="class_">
<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">
@ -10,38 +49,6 @@
</BListGroupItem>
</template>
<script>
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
export default {
name: 'YListGroupItem',
props: {
variant: { type: String, default: 'white' },
icon: { type: String, default: null },
noIcon: { type: Boolean, default: false },
noStatus: { type: Boolean, default: false },
size: { type: String, default: 'md' },
faded: { type: Boolean, default: false },
},
computed: {
_icon() {
return this.noIcon ? null : this.icon || DEFAULT_STATUS_ICON[this.variant]
},
_class() {
const baseClass = 'yuno-list-group-item-'
return [
baseClass + this.size,
baseClass + this.variant,
{ [baseClass + 'faded']: this.faded },
]
},
},
}
</script>
<style lang="scss" scoped>
.yuno-list-group-item {
display: flex;
@ -61,15 +68,15 @@ export default {
@each $color, $value in $theme-colors {
&-#{$color} {
color: theme-color-level($color, 6);
color: tint-color($value, 50%);
[dark-theme='true'] & {
color: theme-color-level($color, -6);
[data-bs-theme='light'] & {
color: shade-color($value, 60%);
}
.yuno-list-group-item-status {
background-color: $value;
color: color-yiq($value);
color: color-contrast($value);
}
}
}
@ -96,9 +103,9 @@ export default {
}
}
.yuno-list-group-item-content {
color: $black;
}
// .yuno-list-group-item-content {
// color: $black;
// }
}
&-faded > * {

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
sublabel?: string
description?: string
}>(),
{
sublabel: undefined,
description: undefined,
},
)
const slots = defineSlots<{
default?: any
}>()
</script>
<template>
<BListGroupItem
class="d-flex justify-content-between align-items-center pe-0"
>
<div>
<h5>
<strong class="fw-bold">{{ label }}</strong>
<small v-if="sublabel" class="ms-1 text-secondary">
{{ sublabel }}
</small>
</h5>
<p v-if="description || slots.default" class="m-0">
<slot name="default">
{{ description }}
</slot>
</p>
</div>
<YIcon iname="chevron-right" class="lg fs-sm ms-auto" />
</BListGroupItem>
</template>
<style lang="scss" scoped></style>

View file

@ -1,19 +1,13 @@
<script setup lang="ts">
import { useSettings } from '@/composables/useSettings'
const { spinner } = useSettings()
</script>
<template>
<div :class="['custom-spinner', spinner]" />
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'YSpinner',
computed: {
...mapGetters(['spinner']),
},
}
</script>
<style lang="scss" scoped>
.custom-spinner {
animation: 8s linear infinite;
@ -25,7 +19,7 @@ export default {
background-image: url('../../assets/spinners/pacman_dark.gif');
animation-name: back-and-forth-pacman;
[dark-theme='true'] & {
[data-bs-theme='dark'] & {
background-image: url('../../assets/spinners/pacman_light.gif');
}

View file

@ -0,0 +1,71 @@
<script setup lang="ts">
import type {
AdressItemProps,
AdressModelValue,
BaseItemComputedProps,
} from '@/types/form'
withDefaults(defineProps<AdressItemProps & BaseItemComputedProps>(), {
id: undefined,
name: undefined,
placeholder: undefined,
touchKey: undefined,
type: 'email',
state: undefined,
validation: undefined,
ariaDescribedby: undefined,
})
const emit = defineEmits<{
'update:modelValue': [value: AdressModelValue]
}>()
const model = defineModel<AdressModelValue>({ required: true })
function onInput(key: 'localPart' | 'domain', value: string | null) {
emit('update:modelValue', {
...model.value,
[key]: value,
})
}
</script>
<template>
<BInputGroup v-bind="$attrs">
<InputItem
:id="`${id}-local-part`"
:placeholder="placeholder"
touch-key="localPart"
:model-value="model.localPart"
:aria-describedby="`${id}-local-part-desc`"
:state="validation?.localPart?.$error ? false : null"
:validation="validation?.localPart"
@update:model-value="onInput('localPart', $event as string)"
/>
<BInputGroupText>{{ modelValue.separator }}</BInputGroupText>
<SelectItem
:id="`${id}-domain`"
touch-key="domain"
:model-value="modelValue.domain"
:choices="choices"
:aria-describedby="`${id}-domain-desc`"
:state="validation?.domain?.$error ? false : null"
:validation="validation?.domain"
@update:model-value="onInput('domain', $event)"
/>
</BInputGroup>
<span
:id="`${id}-local-part-desc`"
v-t="'address.local_part_description.' + type"
class="visually-hidden"
/>
<span
:id="`${id}-domain-desc`"
v-t="'address.domain_description.' + type"
class="visually-hidden"
/>
</template>

View file

@ -1,39 +1,39 @@
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { ButtonItemProps } from '@/types/form'
const props = withDefaults(defineProps<ButtonItemProps>(), {
enabled: true,
icon: undefined,
type: 'success',
})
const emit = defineEmits<{
action: [value: string]
}>()
const icon = computed(() => {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
return props.icon || icons[props.type]
})
</script>
<template>
<BButton
:id="id"
:variant="type"
@click="$emit('action', $event)"
:disabled="!enabled"
:disabled="!toValue(enabled)"
class="d-block mb-3"
@click="emit('action', id)"
>
<YIcon :iname="icon_" class="mr-2" />
<YIcon :iname="icon" class="me-2" />
<span v-html="label" />
</BButton>
</template>
<script>
export default {
name: 'ButtonItem',
props: {
label: { type: String, default: null },
id: { type: String, default: null },
type: { type: String, default: 'success' },
icon: { type: String, default: null },
enabled: { type: [Boolean, String], default: true },
},
computed: {
icon_() {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
return this.icon || icons[this.type]
},
},
}
</script>

View file

@ -1,30 +1,31 @@
<script setup lang="ts">
import type { CheckboxItemProps, BaseItemComputedProps } from '@/types/form'
withDefaults(defineProps<CheckboxItemProps & BaseItemComputedProps>(), {
id: undefined,
name: undefined,
placeholder: undefined,
touchKey: undefined,
label: undefined,
labels: () => ({ true: 'yes', false: 'no' }),
ariaDescribedby: undefined,
state: undefined,
validation: undefined,
})
const modelValue = defineModel<boolean>()
</script>
<template>
<BFormCheckbox
v-model="checked"
v-on="$listeners"
:id="id"
:aria-describedby="$parent.id + '__BV_description_'"
v-model="modelValue"
:name="name"
:aria-describedby="ariaDescribedby"
:state="state"
switch
>
{{ label || $t(labels[checked]) }}
{{ label || $t(labels[modelValue ? 'true' : 'false']) }}
</BFormCheckbox>
</template>
<script>
export default {
name: 'CheckboxItem',
props: {
value: { type: Boolean, required: true },
id: { type: String, default: null },
label: { type: String, default: null },
labels: { type: Object, default: () => ({ true: 'yes', false: 'no' }) },
},
data() {
return {
checked: this.value,
}
},
}
</script>

View file

@ -1,16 +1,13 @@
<script setup lang="ts">
import type { DisplayTextItemProps } from '@/types/form'
withDefaults(defineProps<DisplayTextItemProps>(), {
id: undefined,
})
</script>
<template>
<div>
<div :id="id">
<p v-text="label" />
</div>
</template>
<script>
export default {
name: 'DisplayTextItem',
props: {
id: { type: String, default: null },
label: { type: String, default: null },
},
}
</script>

View file

@ -1,86 +1,108 @@
<template>
<BButtonGroup class="w-100">
<BButton
v-if="!this.required && this.value.file !== null"
@click="clearFiles"
variant="danger"
>
<span class="sr-only">{{ $t('delete') }}</span>
<YIcon iname="trash" />
</BButton>
<script setup lang="ts">
import type { BFormFile } from 'bootstrap-vue-next'
import { computed, inject, ref } from 'vue'
<BFormFile
:value="value.file"
ref="input-file"
:id="id"
:required="required"
:placeholder="_placeholder"
:accept="accept"
:drop-placeholder="dropPlaceholder"
:state="state"
:browse-text="$t('words.browse')"
@input="onInput"
@blur="$parent.$emit('touch', name)"
@focusout.native="$parent.$emit('touch', name)"
/>
</BButtonGroup>
</template>
<script>
import { ValidationTouchSymbol } from '@/composables/form'
import { getFileContent } from '@/helpers/commons'
import type {
BaseItemComputedProps,
FileItemProps,
FileModelValue,
} from '@/types/form'
export default {
name: 'FileItem',
const props = withDefaults(
defineProps<FileItemProps & BaseItemComputedProps>(),
{
id: undefined,
name: undefined,
placeholder: 'Choose a file or drop it here...',
touchKey: undefined,
accept: '',
dropPlaceholder: undefined,
props: {
id: { type: String, default: null },
value: { type: Object, default: () => ({ file: null }) },
placeholder: { type: String, default: 'Choose a file or drop it here...' },
dropPlaceholder: { type: String, default: null },
accept: { type: String, default: null },
state: { type: Boolean, default: null },
required: { type: Boolean, default: false },
name: { type: String, default: null },
ariaDescribedby: undefined,
state: undefined,
validation: undefined,
},
)
computed: {
_placeholder: function () {
return this.value.file === null ? this.placeholder : this.value.file.name
},
},
const emit = defineEmits<{
'update:modelValue': [value: FileModelValue]
}>()
methods: {
onInput(file) {
const value = {
file,
content: '',
current: false,
removed: false,
}
// Update the value with the new File and an empty content for now
this.$emit('input', value)
const modelValue = defineModel<FileModelValue>({
default: () => ({ file: null }),
})
// Asynchronously load the File content and update the value again
getFileContent(file).then((content) => {
this.$emit('input', { ...value, content })
})
},
const touch = inject(ValidationTouchSymbol)
const inputElem = ref<InstanceType<typeof BFormFile> | null>(null)
clearFiles() {
this.$refs['input-file'].reset()
this.$emit('input', {
file: null,
content: '',
current: false,
removed: true,
})
},
},
const placeholder = computed(() => {
return modelValue.value.file === null
? props.placeholder
: modelValue.value.file.name
})
function onInput(file: File | File[] | null) {
const value = {
file: file as File | null,
content: file !== null ? '' : null,
current: false,
removed: false,
}
// Update the value with the new File and an empty content for now
emit('update:modelValue', value)
// Asynchronously load the File content and update the value again
getFileContent(file as File).then((content) => {
emit('update:modelValue', { ...value, content })
})
}
function clearFiles() {
inputElem.value!.reset()
emit('update:modelValue', {
file: null,
content: '',
current: false,
removed: true,
})
}
const required = computed(() => 'required' in (props.validation ?? {}))
</script>
<template>
<BInputGroup class="w-100">
<template v-if="!required && modelValue.file !== null" #append>
<BButton variant="danger" @click="clearFiles">
<span class="visually-hidden">{{ $t('delete') }}</span>
<YIcon iname="trash" />
</BButton>
</template>
<BFormFile
:id="id"
ref="inputElem"
:name="name"
:placeholder="placeholder"
:accept="accept"
:drop-placeholder="dropPlaceholder"
:aria-describedby="ariaDescribedby"
:model-value="modelValue.file"
:state="state"
:browse-text="$t('words.browse')"
:required="required"
@blur="touch?.(touchKey)"
@focusout="touch?.(touchKey)"
@update:model-value="onInput"
/>
</BInputGroup>
</template>
<style lang="scss" scoped>
::v-deep .custom-file-label {
// fix https://getbootstrap.com/docs/5.2/migration/#forms
:deep(.custom-file-label) {
color: $input-placeholder-color;
.btn-danger + .b-form-file & {

View file

@ -1,49 +1,74 @@
<script setup lang="ts">
import type { BaseValidation } from '@vuelidate/core'
import { computed, inject } from 'vue'
import { ValidationTouchSymbol } from '@/composables/form'
import type { BaseItemComputedProps, InputItemProps } from '@/types/form'
import { objectGet } from '@/helpers/commons'
const props = withDefaults(
defineProps<InputItemProps & BaseItemComputedProps>(),
{
id: undefined,
name: undefined,
placeholder: undefined,
touchKey: undefined,
autocomplete: undefined,
// pattern: undefined,
step: undefined,
trim: true,
type: 'text',
ariaDescribedby: undefined,
state: undefined,
validation: undefined,
},
)
const modelValue = defineModel<string | number | null>({
set(value) {
if (props.type === 'number' && typeof value === 'string') {
if (value === '') return ''
return parseInt(value)
}
return value
},
})
const touch = inject(ValidationTouchSymbol)
const autocomplete = computed(() => {
const typeToAutocomplete = {
password: 'new-password',
email: 'email',
url: 'url',
} as const
return props.autocomplete || objectGet(typeToAutocomplete, props.type)
})
const fromValidation = computed(() => {
const validation = props?.validation ?? ({} as BaseValidation)
return {
required: 'required' in validation,
min: 'min' in validation ? validation.min.$params.min : undefined,
max: 'max' in validation ? validation.max.$params.max : undefined,
}
})
</script>
<template>
<BFormInput
:value="value"
:id="id"
v-bind="fromValidation"
v-model="modelValue"
:name="name"
:placeholder="placeholder"
:type="type"
:state="state"
:required="required"
:min="min"
:max="max"
:autocomplete="autocomplete"
:step="step"
:trim="trim"
:autocomplete="autocomplete_"
v-on="$listeners"
@blur="$parent.$emit('touch', name)"
:type="type"
:aria-describedby="ariaDescribedby"
:state="state"
@blur="touch?.(touchKey)"
/>
</template>
<script>
export default {
name: 'InputItem',
props: {
value: { type: [String, Number], default: null },
id: { type: String, default: null },
placeholder: { type: String, default: null },
type: { type: String, default: 'text' },
required: { type: Boolean, default: false },
state: { type: Boolean, default: null },
min: { type: Number, default: null },
max: { type: Number, default: null },
step: { type: Number, default: null },
trim: { type: Boolean, default: true },
autocomplete: { type: String, default: null },
pattern: { type: Object, default: null },
name: { type: String, default: null },
},
data() {
return {
autocomplete_: this.autocomplete
? this.autocomplete
: this.type === 'password'
? 'new-password'
: null,
}
},
}
</script>

View file

@ -1,14 +1,11 @@
<template>
<VueShowdown :markdown="label" flavor="github" />
</template>
<script setup lang="ts">
import type { MarkdownItemProps } from '@/types/form'
<script>
export default {
name: 'MarkdownItem',
props: {
id: { type: String, default: null },
label: { type: String, default: null },
},
}
withDefaults(defineProps<MarkdownItemProps>(), {
id: undefined,
})
</script>
<template>
<VueShowdown :id="id" :markdown="label" />
</template>

View file

@ -1,41 +1,30 @@
<template>
<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" />
<script setup lang="ts">
import { computed } from 'vue'
<VueShowdown
:markdown="label"
flavor="github"
tag="span"
class="markdown"
/>
</BAlert>
</template>
import type { ReadOnlyAlertItemProps } from '@/types/form'
<script>
export default {
name: 'ReadOnlyAlertItem',
const props = withDefaults(defineProps<ReadOnlyAlertItemProps>(), {
id: undefined,
icon: undefined,
type: 'success',
})
props: {
id: { type: String, default: null },
label: { type: String, default: null },
type: { type: String, default: null },
icon: { type: String, default: null },
},
const icon = computed(() => {
// TODO merge with `DEFAULT_VARIANT_ICON`
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
computed: {
icon_() {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
return this.icon || icons[this.type]
},
},
}
return props.icon || icons[props.type]
})
</script>
<template>
<!-- TODO ally: do we set it as a true alert or is it cosmetic? -->
<YAlert :id="id" alert :icon="icon" :variant="type">
<VueShowdown :markdown="label" tag="span" class="markdown" />
</YAlert>
</template>

View file

@ -1,24 +1,64 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { ValidationTouchSymbol } from '@/composables/form'
import type { BaseItemComputedProps, SelectItemProps } from '@/types/form'
const props = withDefaults(
defineProps<SelectItemProps & BaseItemComputedProps>(),
{
id: undefined,
name: undefined,
placeholder: undefined,
touchKey: undefined,
ariaDescribedby: undefined,
modelValue: undefined,
state: undefined,
validation: undefined,
},
)
defineEmits<{
'update:modelValue': [value: string | null]
}>()
const model = defineModel<string | number | null>({
set: (value) => {
if (value === 'null') {
return null
}
return value
},
})
const isOptionalSelectOption = computed(() => {
// FIXME `None` handling for config panels is a bit weird
return props.choices?.some(
(choice) => typeof choice !== 'string' && choice.value === '_none',
)
})
const touch = inject(ValidationTouchSymbol)
const required = computed(() => 'required' in (props?.validation ?? {}))
</script>
<template>
<BFormSelect
:value="value"
:id="id"
v-model="model"
:name="name"
:options="choices"
:aria-describedby="ariaDescribedby"
:state="state"
:required="required"
v-on="$listeners"
@blur.native="$emit('blur', value)"
/>
@blur="touch?.(touchKey)"
>
<template v-if="!isOptionalSelectOption" #first>
<BFormSelectOption value="null" :disabled="required">
-- {{ required ? $t('select_an_option') : $t('words.none') }} --
</BFormSelectOption>
</template>
</BFormSelect>
</template>
<script>
export default {
name: 'SelectItem',
props: {
value: { type: [String, null], default: null },
id: { type: String, default: null },
choices: { type: [Array, Object], required: true },
required: { type: Boolean, default: false },
name: { type: String, default: null },
},
}
</script>

View file

@ -1,37 +1,47 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { ValidationTouchSymbol } from '@/composables/form'
import type { BaseItemComputedProps, TagsItemProps } from '@/types/form'
const props = withDefaults(
defineProps<TagsItemProps & BaseItemComputedProps>(),
{
id: undefined,
name: undefined,
placeholder: undefined,
touchKey: undefined,
limit: undefined,
// options: undefined,
ariaDescribedby: undefined,
state: undefined,
validation: undefined,
},
)
const touch = inject(ValidationTouchSymbol)
const modelValue = defineModel<string[]>()
const required = computed(() => 'required' in (props?.validation ?? {}))
// FIXME rework for options/choices
// https://bootstrap-vue-next.github.io/bootstrap-vue-next/docs/components/form-tags.html#using-custom-form-components
</script>
<template>
<BFormTags
v-model="tags"
:id="id"
v-model="modelValue"
:name="name"
:placeholder="placeholder"
:required="required"
separator=" ,;"
:limit="limit"
remove-on-delete
:aria-describedby="ariaDescribedby"
:state="state"
:options="options"
v-on="$listeners"
@blur="$parent.$emit('touch', name)"
:required="required"
remove-on-delete
separator=" ,;"
@blur="touch?.(touchKey)"
/>
</template>
<script>
export default {
name: 'TagsItem',
data() {
return {
tags: this.value,
}
},
props: {
value: { type: Array, default: null },
id: { type: String, default: null },
placeholder: { type: String, default: null },
limit: { type: Number, default: null },
required: { type: Boolean, default: false },
state: { type: Boolean, default: null },
name: { type: String, default: null },
options: { type: Array, default: null },
},
}
</script>

View file

@ -1,13 +1,131 @@
<script setup lang="ts">
import type { BDropdown, BFormInput } from 'bootstrap-vue-next'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { fromEntries } from '@/helpers/commons'
import type {
BaseItemComputedProps,
Choice,
TagUpdateArgs,
TagsSelectizeItemProps,
} from '@/types/form'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<TagsSelectizeItemProps & BaseItemComputedProps>(),
{
id: undefined,
name: undefined,
placeholder: undefined,
touchKey: undefined,
auto: false,
disabledItems: undefined,
label: undefined,
limit: undefined,
noTags: false,
tagIcon: undefined,
ariaDescribedby: undefined,
state: undefined,
validation: undefined,
},
)
const emit = defineEmits<{
'tag-update': [value: TagUpdateArgs]
}>()
const modelValue = defineModel<string[]>()
const searchElem = ref<InstanceType<typeof BDropdown> | null>(null)
const dropdownElem = ref<InstanceType<typeof BFormInput> | null>(null)
const { t } = useI18n()
const search = ref('')
const criteria = computed(() => {
return search.value.trim().toLowerCase()
})
const availableOptions = computed(() => {
return props.options.filter((opt) => {
const tag = typeof opt === 'string' ? opt : opt.value
let filterIn =
modelValue.value?.indexOf(tag) === -1 &&
!(props.disabledItems?.includes(tag) ?? false)
if (filterIn && criteria.value) {
filterIn = tag.toLowerCase().indexOf(criteria.value) > -1
}
return filterIn
})
})
const texts = computed(() =>
fromEntries(
props.options.map((opt) => {
const tag = typeof opt === 'string' ? opt : opt.value
const text = typeof opt === 'string' ? opt : opt.text
return [tag, text]
}),
),
)
const searchI18n = computed(() => {
const params = { items: t('items.' + props.itemsName, 0) }
return {
label: t('search.for', { items: props.itemsName }),
invalidFeedback: t('search.not_found', params, 0),
noItems: t('items_verbose_items_left', params, 0),
}
})
const searchState = computed(() => {
return criteria.value && availableOptions.value.length === 0 ? false : null
})
function onAddTag(option: Choice, applyFn: TagUpdateArgs['applyFn']) {
const tag = typeof option === 'string' ? option : option.value
emit('tag-update', { action: 'add', tag, applyFn })
search.value = ''
if (props.auto) {
applyFn(tag)
}
}
function onRemoveTag(option: Choice, applyFn: TagUpdateArgs['applyFn']) {
const tag = typeof option === 'string' ? option : option.value
emit('tag-update', { action: 'remove', tag, applyFn })
if (props.auto) {
applyFn(tag)
}
}
function onDropdownKeydown(e: KeyboardEvent) {
// Allow to start searching after dropdown opening
// FIXME check if dropdownElem.value!.firstElementChild works (removed the $el)
if (
!['Tab', 'Space'].includes(e.code) &&
e.target === dropdownElem.value!.$el.firstElementChild
) {
searchElem.value!.$el.focus()
}
}
// FIXME call touch somewhere?
</script>
<template>
<div class="tags-selectize">
<BFormTags
v-bind="$attrs"
v-on="$listeners"
:value="value"
:id="id"
v-model="modelValue"
:name="name"
:aria-describedby="ariaDescribedby"
:state="state"
no-outer-focus
size="lg"
class="p-0 border-0"
no-outer-focus
>
<template #default="{ tags, disabled, addTag, removeTag }">
<ul
@ -20,22 +138,22 @@
class="list-inline-item"
>
<BFormTag
@remove="onRemoveTag({ option: tag, removeTag })"
:title="tag"
:disabled="disabled || disabledItems.includes(tag)"
:disabled="disabled || (disabledItems?.includes(tag) ?? false)"
class="border border-dark mb-2"
@remove="onRemoveTag(tag, removeTag)"
>
<YIcon v-if="tagIcon" :iname="tagIcon" /> {{ tag }}
<YIcon v-if="tagIcon" :iname="tagIcon" /> {{ texts[tag] }}
</BFormTag>
</li>
</ul>
<BDropdown
ref="dropdown"
ref="dropdownElem"
variant="outline-dark"
block
menu-class="w-100"
@keydown.native="onDropdownKeydown"
@keydown="onDropdownKeydown"
>
<template #button-content>
<YIcon iname="search-plus" /> {{ label }}
@ -44,26 +162,23 @@
<BDropdownGroup class="search-group">
<BDropdownForm @submit.stop.prevent="() => {}">
<BFormGroup
:label="$t('search.for', { items: itemsName })"
:label="searchI18n.label"
:label-for="id + '-search-input'"
label-cols-md="auto"
label-size="sm"
:label-for="id + '-search-input'"
:invalid-feedback="
$tc('search.not_found', 0, {
items: $tc('items.' + itemsName, 0),
})
"
:invalid-feedback="searchI18n.invalidFeedback"
:state="searchState"
:disabled="disabled"
class="mb-0"
>
<BFormInput
ref="search-input"
v-model="search"
:id="id + '-search-input'"
type="search"
size="sm"
ref="searchElem"
v-model="search"
autocomplete="off"
size="sm"
type="search"
@click.stop
/>
</BFormGroup>
</BDropdownForm>
@ -71,19 +186,15 @@
</BDropdownGroup>
<BDropdownItemButton
v-for="option in availableOptions"
:key="option"
@click="onAddTag({ option, addTag })"
v-for="(option, i) in availableOptions"
:key="i"
@click="onAddTag(option, addTag)"
>
{{ option }}
{{ typeof option === 'string' ? option : option.text }}
</BDropdownItemButton>
<BDropdownText v-if="!criteria && availableOptions.length === 0">
<YIcon iname="exclamation-triangle" />
{{
$tc('items_verbose_items_left', 0, {
items: $tc('items.' + itemsName, 0),
})
}}
{{ searchI18n.noItems }}
</BDropdownText>
</BDropdown>
</template>
@ -91,92 +202,8 @@
</div>
</template>
<script>
export default {
name: 'TagsSelectizeItem',
inheritAttrs: false,
props: {
value: { type: Array, required: true },
options: { type: Array, required: true },
id: { type: String, required: true },
placeholder: { type: String, default: null },
limit: { type: Number, default: null },
name: { type: String, default: null },
itemsName: { type: String, required: true },
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 },
},
data() {
return {
search: '',
}
},
computed: {
criteria() {
return this.search.trim().toLowerCase()
},
availableOptions() {
const criteria = this.criteria
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
},
searchState() {
return this.criteria && this.availableOptions.length === 0 ? false : null
},
},
methods: {
onAddTag({ option, addTag }) {
this.$emit('tag-update', { action: 'add', option, applyMethod: addTag })
this.search = ''
if (this.auto) {
addTag(option)
}
},
onRemoveTag({ option, removeTag }) {
this.$emit('tag-update', {
action: 'remove',
option,
applyMethod: removeTag,
})
if (this.auto) {
removeTag(option)
}
},
onDropdownKeydown(e) {
// Allow to start searching after dropdown opening
if (
!['Tab', 'Space'].includes(e.code) &&
e.target === this.$refs.dropdown.$el.lastElementChild
) {
this.$refs['search-input'].focus()
}
},
},
}
</script>
<style lang="scss" scoped>
::v-deep .dropdown-menu {
:deep(.dropdown-menu) {
max-height: 300px;
overflow-y: auto;
padding-top: 0;
@ -185,7 +212,14 @@ export default {
padding-top: 0.5rem;
position: sticky;
top: 0;
background-color: $white;
}
}
// FIXME bvn fix (should be fixed in lib)
:deep(.btn-group) {
display: block;
.btn {
width: 100%;
}
}
</style>

View file

@ -1,28 +1,46 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { ValidationTouchSymbol } from '@/composables/form'
import type { BaseItemComputedProps, TextAreaItemProps } from '@/types/form'
const props = withDefaults(
defineProps<TextAreaItemProps & BaseItemComputedProps>(),
{
id: undefined,
name: undefined,
placeholder: undefined,
touchKey: undefined,
// type: 'text',
ariaDescribedby: undefined,
modelValue: undefined,
state: undefined,
validation: undefined,
},
)
defineEmits<{
'update:modelValue': [value: string | null]
}>()
const modelValue = defineModel<string>()
const touch = inject(ValidationTouchSymbol)
const required = computed(() => 'required' in (props?.validation ?? {}))
</script>
<template>
<BFormTextarea
:value="value"
:id="id"
v-model="modelValue"
:name="name"
:placeholder="placeholder"
:required="required"
:aria-describedby="ariaDescribedby"
:state="state"
:required="required"
rows="4"
v-on="$listeners"
@blur="$parent.$emit('touch', name)"
@blur="touch?.(touchKey)"
/>
</template>
<script>
export default {
name: 'TextAreaItem',
props: {
value: { type: String, default: null },
id: { type: String, default: null },
placeholder: { type: String, default: null },
type: { type: String, default: 'text' },
required: { type: Boolean, default: false },
state: { type: Boolean, default: null },
name: { type: String, default: null },
},
}
</script>

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import { randint } from '@/helpers/commons'
</script>
<template>
<BSkeletonWrapper button search>
<BInputGroup class="w-100 mb-4">
<BInputGroupText>
<YIcon iname="search" />
</BInputGroupText>
<BFormInput :disabled="true" />
</BInputGroup>
<BCardGroup deck>
<BCard v-for="i in 15" :key="i" no-body>
<div class="d-flex w-100 mt-auto">
<BSkeleton width="30px" height="30px" class="me-2 ms-auto" />
<BSkeleton
:width="randint(30, 70) + '%'"
height="30px"
class="me-auto"
/>
</div>
<BSkeleton
v-if="randint(0, 1)"
:width="randint(30, 85) + '%'"
height="24px"
class="mx-auto"
/>
<BSkeleton
:width="randint(30, 85) + '%'"
height="24px"
class="mx-auto mb-auto"
/>
</BCard>
</BCardGroup>
</BSkeletonWrapper>
</template>
<style lang="scss" scoped>
.card {
min-height: 10rem;
flex-basis: 100% !important;
@include media-breakpoint-up(md) {
flex-basis: 50% !important;
max-width: calc(50% - 0.75rem);
}
@include media-breakpoint-up(lg) {
flex-basis: 33% !important;
max-width: calc(33.3% - 1rem);
}
}
</style>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
withDefaults(defineProps<{ height?: string; width?: string }>(), {
height: '26px',
width: '100%',
})
</script>
<template>
<div :style="{ height, width }" class="b-skeleton" />
</template>
<style scoped lang="scss">
.b-skeleton {
position: relative;
overflow: hidden;
background-color: var(--bs-secondary-bg);
cursor: wait;
height: $font-size-base;
margin-bottom: map-get($spacers, 1);
@if $enable-rounded {
border-radius: 0.25rem;
}
}
</style>

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
withDefaults(defineProps<{ button: boolean; search: boolean }>(), {
button: false,
search: false,
})
defineSlots<{
default: any
}>()
</script>
<template>
<div class="y-skeleton-wrapper">
<div class="visually-hidden">
{{ $t('loading') }}
</div>
<div v-if="search || button" id="top-bar-skeleton" class="d-flex mb-3">
<div id="search-skeleton" class="top-bar-group-skeleton">
<BInputGroup v-if="search" class="pe-none" aria-hidden="true">
<BInputGroupText>
<YIcon iname="search" />
</BInputGroupText>
<BFormInput :disabled="true" tabindex="-1" />
</BInputGroup>
</div>
<div v-if="button" id="button-skeleton" class="top-bar-group-skeleton">
<BSkeleton height="36px" class="ms-3-md" />
</div>
</div>
<slot name="default" />
</div>
</template>
<style scoped lang="scss">
.y-skeleton-wrapper {
cursor: wait;
#top-bar-skeleton {
flex-wrap: wrap-reverse;
.top-bar-group-skeleton {
margin-bottom: 1rem;
}
#button-skeleton {
width: 170px;
}
@include media-breakpoint-down(sm) {
.top-bar-group-skeleton {
flex-direction: column-reverse;
}
#button-skeleton {
width: 100%;
}
}
@include media-breakpoint-down(md) {
flex-direction: column-reverse;
#button-skeleton {
margin-bottom: 0.75rem;
:deep(> *) {
margin-bottom: 0.25rem;
}
}
.top-bar-group-skeleton {
justify-content: space-between;
flex-wrap: wrap;
}
}
@include media-breakpoint-up(md) {
#search-skeleton {
flex-grow: 2;
max-width: 50%;
}
#button-skeleton {
margin-left: auto;
}
:deep(.btn) {
margin-left: 0.5rem;
}
}
}
}
</style>

View file

@ -1,32 +0,0 @@
<template>
<BCard>
<template #header>
<BSkeleton width="30%" height="36px" class="m-0" />
</template>
<div v-for="count in itemCount" :key="count">
<template v-if="randint(0, 1)">
<BSkeleton width="100%" height="24px" />
<BSkeleton :width="randint(15, 60) + '%'" height="24px" />
</template>
<BSkeleton v-else :width="randint(45, 100) + '%'" height="24px" />
<BSkeleton :width="randint(20, 30) + '%'" height="38px" class="mt-3" />
<hr />
</div>
</BCard>
</template>
<script>
import { randint } from '@/helpers/commons'
export default {
name: 'CardButtonsSkeleton',
props: {
itemCount: { type: Number, default: 5 },
},
methods: { randint },
}
</script>

View file

@ -1,64 +1,56 @@
<template>
<BCard>
<template #header>
<BSkeleton width="30%" height="36px" class="m-0" />
</template>
<template v-for="count in itemCount">
<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"
/>
</div>
</BCol>
<BCol>
<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" />
</div>
<BSkeleton v-else width="100%" height="38px" />
<BSkeleton :width="randint(15, 35) + '%'" height="19px" />
</BCol>
</BRow>
<hr :key="count + '-hr'" />
</template>
<template #footer>
<div class="d-flex justify-content-end w-100">
<BSkeleton width="100px" height="38px" />
</div>
</template>
</BCard>
</template>
<script>
<script setup lang="ts">
import { randint } from '@/helpers/commons'
import type { Cols } from '@/types/commons'
export default {
name: 'CardFormSkeleton',
props: {
itemCount: { type: Number, default: 5 },
cols: {
type: [Object, null],
default() {
return { md: 4, lg: 2 }
},
},
},
methods: { randint },
}
withDefaults(defineProps<{ itemCount?: number; cols: Cols }>(), {
itemCount: 5,
cols: () => ({ md: 4, lg: 2 }),
})
</script>
<template>
<BSkeletonWrapper>
<BCard>
<template #header>
<BSkeleton width="30%" height="26px" class="m-0" />
</template>
<template v-for="count in itemCount" :key="count">
<BRow :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"
/>
</div>
</BCol>
<BCol>
<div
v-if="count % 2 === 0"
class="w100 d-flex justify-content-between"
>
<BSkeleton width="100%" height="38px" />
<BSkeleton width="38px" height="38px" class="ms-2" />
</div>
<BSkeleton v-else width="100%" height="38px" />
<BSkeleton :width="randint(15, 35) + '%'" height="19px" />
</BCol>
</BRow>
<hr />
</template>
<template #footer>
<div class="d-flex justify-content-end w-100">
<BSkeleton width="100px" height="36px" />
</div>
</template>
</BCard>
</BSkeletonWrapper>
</template>

View file

@ -1,30 +1,24 @@
<template>
<BCard>
<template #header>
<BSkeleton width="30%" height="36px" class="m-0" />
</template>
<BRow v-for="i in itemCount" :key="i" no-gutters>
<BCol cols="5" md="3" xl="3">
<BSkeleton :width="randint(45, 95) + '%'" height="19px" />
</BCol>
<BCol>
<BSkeleton :width="randint(10, 60) + '%'" height="19px" />
</BCol>
</BRow>
</BCard>
</template>
<script>
<script setup lang="ts">
import { randint } from '@/helpers/commons'
export default {
name: 'CardInfoSkeleton',
props: {
itemCount: { type: Number, default: 5 },
},
methods: { randint },
}
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
</script>
<template>
<BSkeletonWrapper>
<BCard>
<template #header>
<BSkeleton width="30%" height="36px" class="m-0" />
</template>
<BRow v-for="i in itemCount" :key="i" no-gutters>
<BCol cols="5" md="3" xl="3">
<BSkeleton :width="randint(45, 95) + '%'" height="19px" />
</BCol>
<BCol>
<BSkeleton :width="randint(10, 60) + '%'" height="19px" />
</BCol>
</BRow>
</BCard>
</BSkeletonWrapper>
</template>

View file

@ -1,34 +1,31 @@
<template>
<BCard no-body>
<template #header>
<BSkeleton width="30%" height="36px" class="m-0" />
</template>
<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>
<BSkeleton :width="randint(30, 80) + '%'" height="24px" class="m-0" />
</BListGroupItem>
</BListGroup>
</BCard>
</template>
<script>
<script setup lang="ts">
import { randint } from '@/helpers/commons'
export default {
name: 'CardListSkeleton',
props: {
itemCount: { type: Number, default: 5 },
},
methods: { randint },
}
withDefaults(defineProps<{ itemCount: number; search: boolean }>(), {
itemCount: 5,
search: false,
})
</script>
<template>
<BSkeletonWrapper :search="search">
<BCard no-body>
<template #header>
<BSkeleton width="30%" height="36px" class="m-0" />
</template>
<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="me-3"
/>
</div>
<BSkeleton :width="randint(30, 80) + '%'" height="24px" class="m-0" />
</BListGroupItem>
</BListGroup>
</BCard>
</BSkeletonWrapper>
</template>

View file

@ -1,22 +1,19 @@
<template>
<BListGroup>
<BListGroupItem v-for="count in itemCount" :key="count">
<BSkeleton :width="randint(15, 25) + '%'" height="24px" class="mb-2" />
<BSkeleton :width="randint(25, 50) + '%'" height="24px" class="m-0" />
</BListGroupItem>
</BListGroup>
</template>
<script>
<script setup lang="ts">
import { randint } from '@/helpers/commons'
export default {
name: 'ListGroupSkeleton',
props: {
itemCount: { type: Number, default: 5 },
},
methods: { randint },
}
withDefaults(
defineProps<{ itemCount: number; button: boolean; search: boolean }>(),
{ itemCount: 5, button: true, search: true },
)
</script>
<template>
<BSkeletonWrapper :button="button" :search="search">
<BListGroup>
<BListGroupItem v-for="count in itemCount" :key="count">
<BSkeleton :width="randint(15, 25) + '%'" height="24px" class="mb-2" />
<BSkeleton :width="randint(25, 50) + '%'" height="24px" class="m-0" />
</BListGroupItem>
</BListGroup>
</BSkeletonWrapper>
</template>

View file

@ -0,0 +1,165 @@
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
import type { VNode } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
ModalError,
ModalReconnecting,
ModalWaiting,
ModalWarning,
} from '@/components/modals'
import { useInfos } from '@/composables/useInfos'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import type { CustomRoute, Skeleton, VueClass } from '@/types/commons'
const { t } = useI18n()
const router = useRouter()
const { routerKey } = useInfos()
const { reconnecting, currentRequest, dismissModal } = useRequests()
const { transitions, transitionName, dark } = useSettings()
const RootView = createReusableTemplate<{
Component: VNode
classes: VueClass
}>()
const quickAddItems: CustomRoute[] = [
{ text: t('users_new'), to: { name: 'user-create' } },
{ text: t('domain_add'), to: { name: 'domain-add' } },
{ text: t('group_new'), to: { name: 'group-create' } },
{ text: t('install'), to: { name: 'app-catalog' } },
]
const skeletons = computed<Skeleton[]>(() => {
const skeleton = router.currentRoute.value.meta.skeleton ?? 'CardInfoSkeleton'
const skeletons = Array.isArray(skeleton) ? skeleton : [skeleton]
return skeletons.map((skeleton) =>
typeof skeleton === 'string' ? { is: skeleton } : skeleton,
)
})
const modalComponent = computed(() => {
if (reconnecting.value) {
return {
is: ModalReconnecting,
props: {
reconnecting: reconnecting.value,
onDismiss: () => (reconnecting.value = undefined),
},
}
}
const request = currentRequest.value
if (!request) return null
const { status, err } = request
if (status === 'error' && err) {
return {
is: ModalError,
props: { request, onDismiss: () => dismissModal(request.id) },
}
} else if (status === 'warning') {
return {
is: ModalWarning,
props: { request, onDismiss: () => dismissModal(request.id) },
}
} else {
return { is: ModalWaiting, props: { request } }
}
})
</script>
<template>
<RootView.define v-slot="{ Component, classes }">
<BOverlay
opacity="0.75"
rounded
:show="!!modalComponent"
:variant="dark ? 'dark' : 'light'"
class="main-overlay"
>
<Suspense>
<Component :is="Component" :class="classes" />
<template #fallback>
<template v-for="({ is, ...props }, i) in skeletons" :key="i">
<Component :is="is" v-bind="props" :class="{ 'mt-3': i !== 0 }" />
</template>
</template>
</Suspense>
<template v-if="modalComponent" #overlay>
<Component :is="modalComponent.is" v-bind="modalComponent.props" />
</template>
</BOverlay>
</RootView.define>
<div class="d-flex align-items-center mt-2 mb-4">
<YBreadcrumb />
<BDropdown
v-if="router.currentRoute.value.name === 'home'"
variant="success"
class="ms-auto"
>
<template #button-content>
<YIcon iname="plus" /> {{ t('quick_add') }}
</template>
<template v-for="(item, i) in quickAddItems" :key="i">
<BDropdownItem :to="item.to">
<YIcon iname="plus" /> {{ item.text }}
</BDropdownItem>
</template>
</BDropdown>
</div>
<main id="main">
<!-- The `key` on RouterView make sure that if a link points to a page that
use the same component as the previous one, it will be refreshed -->
<RouterView v-slot="{ Component }" :key="routerKey">
<Transition v-if="transitions" :name="transitionName">
<RootView.reuse v-bind="{ Component, classes: 'animated' }" />
</Transition>
<RootView.reuse v-else v-bind="{ Component, classes: 'static' }" />
</RouterView>
</main>
</template>
<style lang="scss" scoped>
main {
position: relative;
// Routes transition
.animated {
transition: all 0.15s ease-in-out;
}
.slide-left-enter-from,
.slide-right-leave-active {
position: absolute;
width: 100%;
top: 0;
transform: translate(100vw, 0);
}
.slide-left-leave-active,
.slide-right-enter-from {
position: absolute;
width: 100%;
top: 0;
transform: translate(-100vw, 0);
}
// hack to hide last transition provoqued by the <RouterView> element change
// while disabling the transitions in ToolWebAdmin
.static ~ .animated {
display: none;
}
.main-overlay :deep(.b-overlay :first-child) {
width: calc(100% + 20px);
height: calc(100% + 20px);
transform: translate(-10px, -10px);
}
}
</style>

View file

@ -0,0 +1,58 @@
<script setup lang="ts">
import { APIError, APIInternalError } from '@/api/errors'
import ModalOverlay from '@/components/modals/ModalOverlay.vue'
import type { APIRequest } from '@/composables/useRequests'
const props = defineProps<{
request: APIRequest & { err: APIError }
}>()
const { err, messages, traceback } = (() => {
const { err, action } = props.request
return {
err: err,
messages: action?.messages,
traceback: err instanceof APIInternalError ? err.traceback : null,
}
})()
</script>
<template>
<ModalOverlay :request="request" footer-variant="danger" :hide-footer="false">
<h5 v-t="`api_errors_titles.${err.name}`" />
<em v-t="'api_error.sorry'" />
<div class="alert alert-info my-3">
<span v-html="$t('api_error.help')" />
<br />{{ $t('api_error.info') }}
</div>
<!-- FIXME USE DD DL DT -->
<p class="m-0">
<strong v-t="'error'" />:
<code>"{{ err.code }}" {{ err.status }}</code>
</p>
<p>
<strong v-t="'action'" />:
<code>"{{ err.method }}" {{ err.path }}</code>
</p>
<p>
<strong v-t="'api_error.error_message'" />
<YAlert variant="danger" class="mt-2">
<div v-html="err.message" />
</YAlert>
</p>
<div v-if="traceback">
<p><strong v-t="'traceback'" /></p>
<pre><code>{{ traceback }}</code></pre>
</div>
<div v-if="messages">
<p class="my-2"><strong v-t="'api_error.server_said'" /></p>
<MessageListGroup :messages="messages" bordered fixed-height />
</div>
</ModalOverlay>
</template>

View file

@ -0,0 +1,67 @@
<script setup lang="ts">
import type { APIRequest } from '@/composables/useRequests'
withDefaults(
defineProps<{
request: APIRequest
hideFooter?: boolean
}>(),
{
hideFooter: true,
},
)
const emit = defineEmits<{
dismiss: [value: boolean]
}>()
defineSlots<{
default(props: Record<string, any>): any
footer(props: Record<string, any>): any
}>()
</script>
<template>
<BModal
:model-value="true"
class="modal-overlay"
centered
hide-backdrop
no-close-on-backdrop
no-close-on-esc
:hide-footer="hideFooter"
no-fade
>
<template #header>
<QueryHeader type="overlay" :request="request" tabindex="0" />
</template>
<slot name="default" />
<template #footer>
<slot name="footer">
<BButton
v-t="'ok'"
variant="light"
size="sm"
@click="emit('dismiss', true)"
/>
</slot>
</template>
</BModal>
</template>
<style lang="scss">
.modal-overlay {
.modal-header {
padding: 0.5rem 0.75rem;
}
&-status {
display: inline-block;
border-radius: 50%;
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,83 @@
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import api from '@/api'
import ModalOverlay from '@/components/modals/ModalOverlay.vue'
import type { APIRequest, ReconnectingArgs } from '@/composables/useRequests'
import LoginView from '@/views/LoginView.vue'
const props = defineProps<{
reconnecting: ReconnectingArgs
}>()
const emit = defineEmits<{
dismiss: [value: boolean]
}>()
const request = reactive<{
humanRoute: APIRequest['humanRoute']
status: APIRequest['status']
subStatus?: 'expired' | 'failed'
}>({
status: 'pending',
humanRoute: 'reconnecting',
})
function tryToReconnect() {
request.status = 'pending'
request.subStatus = undefined
api
.tryToReconnect(props.reconnecting)
.then(() => {
emit('dismiss', true)
})
.catch((err) => {
if (err.name === 'APIUnauthorizedError') {
request.status = 'success'
request.subStatus = 'expired'
} else {
request.status = 'error'
request.subStatus = 'failed'
}
})
}
onMounted(() => {
tryToReconnect()
})
</script>
<template>
<ModalOverlay
:request="request as APIRequest"
footer-variant="danger"
:hide-footer="request.subStatus !== 'failed'"
>
<h5 v-t="'api.reconnecting.title'" class="text-center my-4" />
<template v-if="request.status === 'pending'">
<YSpinner class="mb-4" />
<YAlert
v-if="!!reconnecting.origin"
v-t="'api.reconnecting.reason.' + reconnecting.origin"
:variant="reconnecting.origin === 'unknown' ? 'warning' : 'info'"
/>
</template>
<template v-if="request.subStatus === 'failed'">
<YAlert variant="danger">
<MarkdownItem :label="$t('api.reconnecting.failed')" />
</YAlert>
</template>
<template v-if="request.subStatus === 'failed'" #footer>
<BButton v-t="'retry'" variant="light" @click="tryToReconnect()" />
</template>
<template v-if="request.subStatus === 'expired'">
<YAlert v-t="'api.reconnecting.success'" variant="success" />
<LoginView force-reload />
</template>
</ModalOverlay>
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import ModalOverlay from '@/components/modals/ModalOverlay.vue'
import type { APIRequest } from '@/composables/useRequests'
const props = defineProps<{
request: APIRequest
}>()
const messages = computed(() => {
const messages = props.request.action?.messages
return messages?.length ? messages : null
})
const progress = computed(() => {
const progress = props.request.action?.progress
if (!progress) return null
return {
values: progress,
max: progress.reduce((sum, value) => sum + value, 0),
}
})
</script>
<template>
<ModalOverlay :request="request">
<h5
v-t="messages || progress ? 'api.processing' : 'api_waiting'"
class="text-center mt-4"
/>
<BProgress v-if="progress" :max="progress.max" height=".5rem" class="my-4">
<BProgressBar variant="success" :value="progress.values[0]" />
<BProgressBar variant="warning" :value="progress.values[1]" animated />
<BProgressBar variant="secondary" :value="progress.values[2]" striped />
</BProgress>
<YSpinner v-else class="my-4" />
<MessageListGroup
v-if="messages"
auto-scroll
bordered
fixed-height
:limit="100"
:messages="messages"
/>
</ModalOverlay>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import ModalOverlay from '@/components/modals/ModalOverlay.vue'
import type { APIRequest } from '@/composables/useRequests'
const props = defineProps<{
request: APIRequest
}>()
// FIXME probably doesn't need a computed here
const warningMessage = computed(() => {
const messages = props.request.action!.messages
return messages[messages.length - 1]
})
</script>
<template>
<ModalOverlay
:request="request"
footer-variant="warning"
body-variant="warning"
:hide-footer="false"
>
<div v-html="warningMessage.text" />
</ModalOverlay>
</template>

View file

@ -0,0 +1,13 @@
import ModalOverlay from './ModalOverlay.vue'
import ModalError from './ModalError.vue'
import ModalWaiting from './ModalWaiting.vue'
import ModalReconnecting from './ModalReconnecting.vue'
import ModalWarning from './ModalWarning.vue'
export {
ModalOverlay,
ModalError,
ModalWaiting,
ModalReconnecting,
ModalWarning,
}

View file

@ -0,0 +1,479 @@
import evaluate from 'simple-evaluate'
import type {
ComputedRef,
MaybeRefOrGetter,
Ref,
WritableComputedRef,
} from 'vue'
import { computed, ref, toValue, watch } from 'vue'
import { useRouter } from 'vue-router'
import { APIBadRequestError, APIError } from '@/api/errors'
import { deepSetErrors, useForm, type FormValidation } from '@/composables/form'
import { isObjectLiteral } from '@/helpers/commons'
import * as validators from '@/helpers/validators'
import { formatForm, formatI18nField } from '@/helpers/yunohostArguments'
import i18n from '@/i18n'
import type { CustomRoute, KeyOfStr, MergeUnion, Obj } from '@/types/commons'
import type {
AnyFormField,
ConfigPanel,
ConfigPanels,
} from '@/types/configPanels'
import { OPTION_COMPONENT_RESOLVER, isIn } from '@/types/configPanels'
import type {
AnyOption,
AnyWritableOption,
CoreConfigPanel,
CoreConfigPanels,
JSExpression,
} from '@/types/core/options'
import {
ANY_DISPLAY_OPTION_TYPE,
ANY_INPUT_OPTION_TYPE,
ANY_WRITABLE_OPTION_TYPE,
} from '@/types/core/options'
import type {
AnyDisplayItemProps,
AnyWritableItemProps,
FormField,
FormFieldDict,
FormFieldDisplay,
FormFieldReadonly,
} from '@/types/form'
import {
isAdressModelValue,
isFileModelValue,
isNonWritableComponent,
} from '@/types/form'
function formatOptionValue(option: AnyWritableOption) {
let value = option.value ?? null
if ('tags' === option.type) {
// FIXME format in core?
if (typeof value === 'string') {
value = value.split(',')
} else if (!value) {
value = []
}
} else if ('boolean' === option.type) {
// FIXME format in core?
if (value !== null) {
value = ['1', 'yes', 'y', 'true'].includes(String(value).toLowerCase())
} else if (option.default !== null && option.default !== undefined) {
value = ['1', 'yes', 'y', 'true'].includes(
String(option.default).toLowerCase(),
)
}
} else if ('file' === option.type) {
value = {
// in case of already defined file, we receive only the file path (not the actual file)
file: value ? new File([''], value) : null,
content: '',
current: !!value,
removed: false,
}
}
if (value === null && option.default !== undefined) {
value = option.default
}
return value
}
/**
* Format app install and config panel Option into a Field that can be consumed
* by form field components.
*
* @param option - a core Option written by a packager
* @param form - a ref containing all related form values for expressions's evaluations
* @return Formated `FormField | FormFieldReadonly | FormFieldDisplay` props with form items props.
*/
function formatOption(option: AnyOption, form: Ref<Obj>): AnyFormField {
const visible = useExpression(option.visible, form)
if (isIn(ANY_DISPLAY_OPTION_TYPE, option)) {
const component = OPTION_COMPONENT_RESOLVER[option.type]
// TODO: could be improved, for simplicity cProps can be be any display item props
// but this is not type safe.
const cProps = {
label: formatI18nField(option.ask),
id: option.id,
} as MergeUnion<AnyDisplayItemProps>
const field: FormFieldDisplay<typeof component> = {
component,
visible,
cProps,
rules: undefined,
}
if (isIn(['button', 'alert'], option)) {
cProps.type = option.style
cProps.icon = option.icon
if (option.type === 'button') {
cProps.enabled = useExpression(option.enabled, form)
}
}
return field
} else if (isIn(ANY_WRITABLE_OPTION_TYPE, option)) {
if ('tags' === option.type && option.choices) {
// TODO: update in core directly?
option.type = 'tags-select'
}
const component = OPTION_COMPONENT_RESOLVER[option.type]
// TODO: could be improved, for simplicity cProps can be be any writable item props
// but this is not type safe.
const cProps = {
id: option.id,
placeholder: option.example,
} as MergeUnion<AnyWritableItemProps>
const rules: FormField['rules'] = {}
const field: FormField<typeof component> = {
component,
label: formatI18nField(option.ask),
rules: option.readonly ? undefined : rules,
visible,
description: formatI18nField(option.help),
}
// We don't care about component props in case of readonly
if (option.readonly) {
return { ...field, readonly: true } as FormFieldReadonly<typeof component>
} else {
field.cProps = cProps
}
const t = i18n.global.t
if (isIn(ANY_INPUT_OPTION_TYPE, option)) {
cProps.type = isIn(['string', 'path'], option) ? 'text' : option.type
// trim
// autocomplete
if (option.type === 'password') {
field.description ??= t('good_practices_about_admin_password')
rules.passwordLenght = validators.minLength(8)
cProps.placeholder = '••••••••••••'
} else if (isIn(['number', 'range'], option)) {
rules.numValue = validators.integer
cProps.step = option.step
if (option.min !== undefined) {
rules.minValue = validators.minValue(option.min)
}
if (option.max !== undefined) {
rules.maxValue = validators.maxValue(option.max)
}
}
} else if (isIn(['select', 'user', 'domain', 'app', 'group'], option)) {
cProps.choices = isObjectLiteral(option.choices)
? Object.entries(option.choices).map(([k, v]) => ({
text: v,
value: k,
}))
: option.choices // FIXME rename choices to options?
if (option.type !== 'select') {
field.link = {
name: option.type + '-list',
text: t(`manage_${option.type}s`),
}
}
} else if (isIn(['tags', 'tags-select'], option)) {
// cProps.limit = option.limit // FIXME limit is not defined in core?
cProps.placeholder = option.placeholder
cProps.tagIcon = option.icon
if ('tags-select' === option.type) {
cProps.options = option.choices
cProps.auto = true
cProps.itemsName = ''
cProps.label = option.placeholder
}
} else if ('boolean' === option.type) {
// FIXME
// cProps.choices = option.choices
}
if ('file' === option.type) {
cProps.accept = option.accept
}
if ('boolean' !== option.type && option.optional === false) {
rules.required = validators.required
}
if (isIn(['string', 'text', 'path', 'url'], option) && option.pattern) {
rules.pattern = validators.helpers.withMessage(
formatI18nField(option.pattern.error),
validators.helpers.regex(new RegExp(option.pattern.regexp)),
)
}
return field
} else {
throw new TypeError(
'Unknown Option type: ' + (option as { type: unknown }).type,
)
}
}
/**
* Format app install and config panel's options into a form and fields that
* can be used to populate `useForm` composable and CardForm component.
*
* @param options - a core Option array written by a packager
* @return An object with form and fields
*/
export function formatOptions<MV extends Obj>(
options: AnyOption[],
): {
fields: FormFieldDict<MV>
form: Ref<MV>
} {
const form = ref(
Object.fromEntries(
options
.filter((option) => isIn(ANY_WRITABLE_OPTION_TYPE, option))
.map((option) => {
return [option.id, formatOptionValue(option as AnyWritableOption)]
}),
),
) as Ref<MV>
return {
form,
fields: Object.fromEntries(
options.map((option) => [option.id, formatOption(option, form)]),
) as FormFieldDict<MV>,
}
}
function formatConfigPanel<NestedMV extends Obj, MV extends Obj<NestedMV>>(
panel: CoreConfigPanel<MV>,
): {
form: Ref<NestedMV>
panel: ConfigPanel<NestedMV, MV>
} {
const options = panel.sections?.flatMap((section) => section.options)
const { form, fields } = options
? formatOptions<NestedMV>(options)
: { form: ref({}) as Ref<NestedMV>, fields: {} as FormFieldDict<NestedMV> }
let hasApplyButton = false
const sections = panel.sections?.map((section) => {
const sectionFieldsIds = section.options.map<
KeyOfStr<FormFieldDict<NestedMV>>
>((option) => option.id)
if (
!section.is_action_section &&
sectionFieldsIds.some((id) => !isNonWritableComponent(fields[id]))
) {
hasApplyButton = true
}
return {
help: formatI18nField(section.help),
fields: sectionFieldsIds,
id: section.id,
isActionSection: section.is_action_section,
name: formatI18nField(section.name),
visible: useExpression(section.visible, form),
}
})
return {
form,
panel: {
fields,
help: formatI18nField(panel.help),
hasApplyButton,
id: panel.id,
name: formatI18nField(panel.name),
sections,
},
}
}
export function formatConfigPanels<
NestedMV extends Obj,
MV extends Obj<NestedMV>,
>(config: CoreConfigPanels<MV>): ConfigPanels<NestedMV, MV> {
return config.panels.reduce(
(cps, panel_) => {
const { form, panel } = formatConfigPanel<NestedMV, MV>(panel_)
cps.forms[panel.id] = form
cps.panels.push(panel)
return cps
},
{
forms: {} as Record<keyof MV, Ref<NestedMV>>,
panels: [],
routes: config.panels.map((panel) => ({
to: { params: { tabId: panel.id } },
text: formatI18nField(panel.name),
icon: panel.icon || 'wrench',
})),
} as ConfigPanels<NestedMV, MV>,
)
}
function useExpression(
expression: JSExpression | undefined,
form: Ref<Obj>,
): boolean | ComputedRef<boolean> {
if (typeof expression === 'boolean') return expression
if (typeof expression === 'string') {
// FIXME normalize expression in core? ('', 'false', 'true') and rm next 2 lines
if (!expression || expression === 'true') return true
if (expression === 'false') return false
return useEvaluation(expression, form)
}
return true
}
/**
* Evaluate config panel string expression that can contain regular expressions.
* Expressions are evaluated with the config panel's form as context.
*
* @param expression - A string expression to evaluate as a boolean
* @param form - An object to serve as evaluation context
* @return A computed boolean
*/
function useEvaluation(expression: string, form: MaybeRefOrGetter<Obj>) {
function buildContext(f: Obj) {
// FIXME deepClone?
const ctx: Obj = { ...f }
let exp = expression
for (const key in ctx) {
if (isFileModelValue(ctx[key])) {
ctx[key] = ctx[key].content
}
if (isAdressModelValue(ctx[key])) {
ctx[key] = Object.values(ctx[key]).join('')
}
}
// Allow to use match(var,regexp) function
const matchRe = /match(\s*(\w+)\s*,\s*"([^"]+)"\s*)/g
for (const matched of expression.matchAll(matchRe)) {
const [fullMatch, varMatch, regExpMatch] = matched
const varName = varMatch + '__re' + matched.index
ctx[varName] = new RegExp(regExpMatch, 'm').test(ctx[varMatch])
exp = expression.replace(fullMatch, varName)
}
return { exp, ctx }
}
return computed(() => {
const { exp, ctx } = buildContext(toValue(form))
try {
return !!evaluate(ctx, exp)
} catch {
return false
}
})
}
export type OnPanelApply<MV extends Obj = Obj> = (
data: { panelId: keyof MV; data: Obj; action?: string },
onError: (err: APIError, errorMessage?: string) => void,
) => void
export type ConfigPanelsProps<
NestedMV extends Obj = Obj,
MV extends Obj<NestedMV> = Obj<NestedMV>,
> = {
form: WritableComputedRef<NestedMV>
panel: ComputedRef<ConfigPanel<NestedMV, MV, FormFieldDict<NestedMV>>>
routes: CustomRoute[]
v: Ref<FormValidation<NestedMV>>
onPanelApply: (actionId?: KeyOfStr<FormFieldDict<NestedMV>>) => void
}
export function useConfigPanels<NestedMV extends Obj, MV extends Obj<NestedMV>>(
config: ConfigPanels<NestedMV, MV>,
tabId: MaybeRefOrGetter<keyof MV | undefined>,
onPanelApply: OnPanelApply<MV>,
): ConfigPanelsProps<NestedMV, MV> {
const router = useRouter()
watch(
() => toValue(tabId),
(id) => {
if (!id) {
router.replace({ params: { tabId: config.panels[0].id } })
}
},
{ immediate: true },
)
const panelId = computed(() => toValue(tabId) || config.panels[0].id)
const panel = computed(() => {
return config.panels.find((panel) => panel.id === panelId.value)!
})
const form = computed({
get: () => config.forms[panelId.value].value,
set: (form) => (config.forms[panelId.value].value = form),
})
const { v, serverErrors } = useForm<NestedMV>(form, () => panel.value.fields)
function onErrorFn(err: APIError) {
if (!(err instanceof APIBadRequestError)) throw err
if (err.data.name) {
deepSetErrors(
serverErrors,
[err.message],
'form',
// FIXME probably need to remove panel + section id
...err.data.name.split('.'),
)
} else {
serverErrors.global = [err.message]
}
}
const onBeforePanelApply = async (
actionId?: KeyOfStr<FormFieldDict<NestedMV>>,
) => {
const panelId = panel.value.id
let form: NestedMV | Partial<NestedMV> = config.forms[panelId].value
let action: undefined | string = undefined
if (actionId) {
const section = panel.value.sections!.find((section) =>
section.fields.includes(actionId),
)!
action = `${panelId}.${section.id}.${actionId}`
const actionForm: Partial<NestedMV> = {}
for (const id of section.fields) {
if (id in form) {
// FIXME check visible? skip validate and value if not visible?
if (!(await v.value.form[id].$validate())) return
actionForm[id] = form[id]
}
}
form = actionForm
} else {
if (!(await v.value.form.$validate())) return
}
const data = await formatForm(form, { removeNullish: true })
onPanelApply({ panelId, data, action }, onErrorFn)
}
return {
form,
panel,
routes: config.routes,
v,
onPanelApply: onBeforePanelApply,
}
}

266
app/src/composables/data.ts Normal file
View file

@ -0,0 +1,266 @@
import { createGlobalState } from '@vueuse/core'
import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
import type { RequestMethod } from '@/api/api'
import { isEmptyValue, isObjectLiteral } from '@/helpers/commons'
import { stratify } from '@/helpers/data/tree'
import type { Obj } from '@/types/commons'
import type {
DomainDetail,
Group,
Permission,
UserDetails,
UserItem,
} from '@/types/core/data'
import { useSettings } from './useSettings'
function getNoDataMessage(key: DataKeys) {
return `No data in cache: you should query '${key}' before.`
}
const useData = createGlobalState(() => {
const users = ref<Obj<UserItem>>({})
const userDetails = ref<Obj<UserDetails>>({})
const groups = ref<Obj<Group>>({})
const permissions = ref<Obj<Permission>>({})
const mainDomain = ref<string | undefined>()
const domains = ref<string[] | undefined>()
const domainDetails = ref<Obj<DomainDetail>>({})
function update(
method: RequestMethod,
payload: any,
key: DataKeys,
param?: string,
) {
if (key === 'users') {
if (method === 'GET') users.value = payload.users
else if (method === 'POST')
users.value[payload.username] = {
...payload,
'mailbox-quota': 'Pas de quota',
groups: [],
}
} else if (key === 'userDetails' && param) {
if (method === 'GET' || method === 'PUT') {
userDetails.value[param] = payload
} else if (method === 'DELETE') {
delete userDetails.value[param]
delete users.value[param]
}
} else if (key === 'permissions') {
if (method === 'GET') {
permissions.value = payload.permissions
} else if (method === 'PUT' && param) {
permissions.value[param] = payload
}
} else if (key === 'groups') {
if (method === 'GET') {
groups.value = payload.groups
} else if (method === 'POST') {
groups.value[payload.name] = { members: [], permissions: [] }
} else if (method === 'PUT' && param) {
groups.value[param] = payload
} else if (method === 'DELETE' && param) {
delete groups.value[param]
}
} else if (key === 'domains') {
if (method === 'GET') {
domains.value = payload.domains
mainDomain.value = payload.main
} else if (param) {
if (method === 'POST') {
// FIXME api should at least return the domain name on
domains.value?.push(param)
} else if (method === 'PUT') {
mainDomain.value = param
} else if (method === 'DELETE') {
domains.value?.splice(domains.value.indexOf(param), 1)
delete domainDetails.value[param]
}
}
} else if (key === 'mainDomain' && method === 'PUT' && param) {
mainDomain.value = param
} else if (key === 'domainDetails' && param && method === 'GET') {
domainDetails.value[param] = payload
} else {
console.warn(
`couldn't update the cache, key: ${key}, method: ${method}, param: ${param}`,
)
}
}
return {
users,
userDetails,
groups,
permissions,
mainDomain,
domains,
domainDetails,
update,
}
})
export function useUsersAndGroups(username?: MaybeRefOrGetter<string>) {
const { users, userDetails } = useData()
return {
users: computed(() => {
const users_ = Object.values(users.value)
if (!users_.length) throw new Error(getNoDataMessage('users'))
return users_
}),
usernames: computed(() => {
const usersnames = Object.keys(users.value)
if (!usersnames.length) throw new Error(getNoDataMessage('users'))
return usersnames
}),
user: computed(() => {
if (!username)
throw new Error(
'You should pass a username to `useUsersAndGroups` to get its details',
)
return userDetails.value[toValue(username)]
}),
}
}
export function useDomains(domain_?: MaybeRefOrGetter<string>) {
const { mainDomain, domains: domains_, domainDetails } = useData()
const domains = computed(() => {
if (!domains_.value) throw new Error(getNoDataMessage('domains'))
return domains_.value
})
const orderedDomains = computed(() => {
const splittedDomains = Object.fromEntries(
domains.value.map((domain) => {
// Keep the main part of the domain and the extension together
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
const domainParts = domain.split('.')
domainParts.push(domainParts.pop()! + domainParts.pop()!)
return [domain, domainParts.reverse()]
}),
)
return domains.value.sort((a, b) =>
splittedDomains[a] > splittedDomains[b] ? 1 : -1,
)
})
function getParentDomain(domain: string, domains: string[], highest = false) {
const method = highest ? 'lastIndexOf' : 'indexOf'
let i = domain[method]('.')
while (i !== -1) {
const dn = domain.slice(i + 1)
if (domains.includes(dn)) return dn
i = domain[method]('.', i + (highest ? -1 : 1))
}
return null
}
return {
maybeMainDomain: mainDomain,
mainDomain: computed(() => {
if (!mainDomain.value) throw new Error(getNoDataMessage('mainDomain'))
return mainDomain.value
}),
domain: computed(() => {
if (!domain_)
throw new Error(
'You should pass a domain name to `useDomains` to get its details',
)
const domain = domainDetails.value[toValue(domain_)]
if (!domain) throw new Error(getNoDataMessage('domainDetails'))
return domain
}),
domains,
domainsAsChoices: computed(() => {
return domains.value.map((domain) => ({
value: domain,
text: domain === mainDomain.value ? domain + ' ★' : domain,
}))
}),
orderedDomains,
domainsTree: computed(() => {
const domains = orderedDomains.value
const dataset = reactive(
domains.map((domain) => ({
// data to build a hierarchy
name: domain,
parent: getParentDomain(domain, domains),
// utility data that will be used by `RecursiveListGroup` component
to: { name: 'domain-info', params: { name: domain } },
opened: true,
})),
)
return stratify(dataset)
}),
}
}
type StoreKeys = 'users' | 'permissions' | 'groups' | 'mainDomain' | 'domains'
type StoreKeysParam =
| 'userDetails'
| 'groups'
| 'permissions'
| 'mainDomain'
| 'domainDetails'
| 'domains'
type DataKeys = StoreKeys | StoreKeysParam
export type StorePath = `${StoreKeys}` | `${StoreKeysParam}.${string}`
export function useCache<T extends any = any>(
method: RequestMethod,
cachePath: StorePath,
) {
const [key, param] = cachePath.split(/\.(.*)/s) as
| [StoreKeys, undefined]
| [StoreKeysParam, string]
const data = useData()
const { cache } = useSettings()
return {
content: computed(() => {
if (!cache.value) return undefined
if (!(key in data)) {
throw new Error('Trying to get cache of inexistant data')
}
const d = data[key].value
if (param) {
if (isObjectLiteral(d) && !Array.isArray(d)) {
return d[param] as T
} else {
return undefined as T
console.warn('Trying to get param on non object data')
}
}
return (isEmptyValue(d) ? undefined : d) as T
}),
update: (payload: T) => {
if (method === 'DELETE') {
// Update the cache with a delay to avoid current view to error out since there's no data anymore
setTimeout(() => {
data.update(method, payload, key, param)
}, 100)
} else {
data.update(method, payload, key, param)
}
},
}
}
export function resetCache(keys: DataKeys[]) {
const data = useData()
for (const key of keys) {
if (['domains', 'mainDomain'].includes(key)) {
data[key].value = undefined
} else {
data[key].value = {}
}
}
}

185
app/src/composables/form.ts Normal file
View file

@ -0,0 +1,185 @@
// eslint-disable-next-line vue/prefer-import-from-vue
import { isFunction } from '@vue/shared'
import type {
BaseValidation,
ServerErrors,
Validation,
ValidationArgs,
} from '@vuelidate/core'
import useVuelidate from '@vuelidate/core'
import { watchImmediate } from '@vueuse/core'
import type {
ComputedRef,
InjectionKey,
MaybeRefOrGetter,
Ref,
WritableComputedRef,
} from 'vue'
import { computed, inject, provide, reactive, ref, toValue } from 'vue'
import { useRouter } from 'vue-router'
import { APIBadRequestError, type APIError } from '@/api/errors'
import { fromEntries, getKeys } from '@/helpers/commons'
import type { Obj } from '@/types/commons'
import type { FormFieldDict } from '@/types/form'
export const clearServerErrorsSymbol = Symbol() as InjectionKey<
(key?: string) => void
>
export const ValidationTouchSymbol = Symbol() as InjectionKey<
(key?: string) => void
>
export function useTouch(
validation: MaybeRefOrGetter<BaseValidation | undefined>,
) {
function touch(key?: string) {
const v = toValue(validation)
if (v) {
// For fields that have multiple elements
if (key && v[key]) {
v[key].$touch()
clear?.(v[key].$path)
} else {
v.$touch()
clear?.(v.$path)
}
}
}
provide(ValidationTouchSymbol, touch)
const clear = inject(clearServerErrorsSymbol)
return touch
}
export type FormValidation<MV extends Obj> = Validation<
{ global: { true: () => true }; form: ValidationArgs<MV> },
{ form: Ref<MV> | WritableComputedRef<MV>; global: null }
>
export function useForm<
MV extends Obj,
FFD extends FormFieldDict<MV> = FormFieldDict<MV>,
>(form: Ref<MV> | WritableComputedRef<MV>, fields: FFD | (() => FFD)) {
const serverErrors = reactive<ServerErrors>({})
const validByDefault = { true: () => true as const }
// create a fake validation rule for global state to be able to add $externalResult errors to it
const rules = ref({ global: validByDefault, form: {} }) as Ref<{
global: { true: () => true }
form: ValidationArgs<MV>
}>
function updateRules(ffd: FFD) {
const validations = Object.keys(form.value).map((key: keyof MV) => [
key,
ffd[key].rules ?? validByDefault,
])
const formRules: ValidationArgs<MV> = Object.fromEntries(validations)
rules.value = { global: { true: () => true }, form: formRules }
}
if (isFunction(fields)) {
watchImmediate(fields, () => {
updateRules(toValue(fields))
})
} else {
watchImmediate(
Object.keys(form.value).map((key: keyof MV) => () => fields[key].rules),
() => updateRules(fields),
)
}
const v: Ref<FormValidation<MV>> = useVuelidate(
rules,
{ form, global: null },
{ $externalResults: serverErrors },
)
function onErrorFn(err: APIError, errorMessage?: string) {
if (!(err instanceof APIBadRequestError)) throw err
if (errorMessage || !err.data.name) {
serverErrors.global = [errorMessage || err.message]
} else {
deepSetErrors(
serverErrors,
[err.message],
'form',
...err.data.name.split('.'),
)
}
}
function onSubmit(
fn: (onError: typeof onErrorFn, serverErrors: ServerErrors) => void,
) {
// FIXME add option to ask confirmation (with param text confirm)
return async (e: SubmitEvent) => {
e.preventDefault()
if (!(await v.value.form.$validate())) return
fn(onErrorFn, serverErrors)
}
}
provide(clearServerErrorsSymbol, (key?: string) => {
const keys = key?.split('.')
if (keys?.length) {
deepSetErrors(serverErrors, [], ...keys)
}
})
return {
v,
serverErrors,
onSubmit,
}
}
export function deepSetErrors(
serverErrors: ServerErrors,
value: string[],
...keys: string[]
) {
const [k, ...ks] = keys
if (ks.length) {
if (!(k in serverErrors) && !value.length) {
serverErrors[k] = {}
deepSetErrors(serverErrors[k] as ServerErrors, value, ...ks)
} else if (k in serverErrors) {
deepSetErrors(serverErrors[k] as ServerErrors, value, ...ks)
}
} else {
if (!(k in serverErrors) && !value.length) return
serverErrors[k] = value
}
}
export function useArrayRule<V extends any[], T extends ValidationArgs>(
values: MaybeRefOrGetter<V>,
rules: T,
): ComputedRef<ValidationArgs<T>> {
return computed(() => {
return toValue(values).reduce((total: Obj<T>, v: V[number], index) => {
total[index] = rules
return total
}, {})
})
}
export function useFormQuery<T extends Obj>(
props: T,
onUpdate?: () => T | undefined,
) {
const router = useRouter()
const formQuery = fromEntries(
getKeys(props).map((key) => [
key,
computed({
get: () => props[key],
set: (n) => {
const nextProps = onUpdate?.() ?? props
router.replace({ query: { ...nextProps, [key]: n } })
},
}),
]) as { [K in keyof T]: [K, WritableComputedRef<T[K]>] }[keyof T][],
)
return formQuery
}

View file

@ -0,0 +1,45 @@
import type { OrchestratedModal } from 'bootstrap-vue-next'
import { BModal, useModalController } from 'bootstrap-vue-next'
import { h } from 'vue'
import { useI18n } from 'vue-i18n'
import { VueShowdown } from 'vue-showdown'
export function useAutoModal() {
const { t } = useI18n()
const { confirm, show } = useModalController()
return function (
message: string,
props_: OrchestratedModal = {},
{ markdown = false, cancelable = true } = {},
) {
const props: OrchestratedModal = {
okTitle: t('ok'),
cancelTitle: t('cancel'),
centered: true,
okOnly: !cancelable,
...(markdown
? { headerVariant: 'warning' }
: {
hideHeader: true,
bodyVariant: 'warning',
bodyClass: ['fw-bold', 'rounded-top'],
}),
...props_,
}
const fn = cancelable ? confirm : show
return fn?.({
props,
component: h(BModal, null, {
default: () =>
markdown
? h(VueShowdown, {
markdown: message,
options: { headerLevelStart: 3 },
})
: message,
}),
})
}
}

View file

@ -0,0 +1,219 @@
import { createGlobalState, useLocalStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type {
RouteLocationNormalized,
RouteLocationNormalizedLoaded,
RouteMeta,
RouteParamsGeneric,
RouteRecordNameGeneric,
} from 'vue-router'
import { useRouter } from 'vue-router'
import api from '@/api'
import { timeout } from '@/helpers/commons'
import i18n from '@/i18n'
import { useDomains } from './data'
import { useRequests, type ReconnectingArgs } from './useRequests'
type BreadcrumbRoutes = {
name: RouteRecordNameGeneric
params: RouteParamsGeneric
args: RouteMeta['args']
}
function formatRoute({ params, args }: BreadcrumbRoutes) {
const { trad, param } = args
// if a traduction key string has been given and we also need to pass
// the route param as a variable.
if (trad && param) {
return i18n.global.t(trad, { [param]: params[param] })
} else if (trad) {
return i18n.global.t(trad)
} else if (param) {
return params[param] as string
}
return ''
}
export const useInfos = createGlobalState(() => {
const router = useRouter()
const host = ref(window.location.host)
const installed = ref<boolean | undefined>()
const connected = useLocalStorage('connected', false)
const yunohost = ref<{ version: string; repo: string } | undefined>()
const routerKey = ref<string | undefined>()
const breadcrumbRoutes = ref<BreadcrumbRoutes[]>([])
const breadcrumb = computed(() => {
return breadcrumbRoutes.value.map((to) => {
return { to: { name: to.name }, text: formatRoute(to) }
})
})
const htmlTitle = computed(() => {
const bc = breadcrumb.value
if (bc.length === 0) {
const { name, params, meta } = router.currentRoute.value
return formatRoute({ name, params, args: meta.args || {} })
}
return (bc.length > 2 ? bc.slice(-2) : bc)
.map((route) => route.text)
.reverse()
.join(' / ')
})
const { maybeMainDomain } = useDomains()
const ssoLink = computed(() => {
return `//${maybeMainDomain.value ?? host.value}/yunohost/sso`
})
watch(router.currentRoute, (to) => {
updateRouterKey()
const routeNames =
to.meta.breadcrumb ||
to.matched
.slice()
.reverse()
.find((route) => route.meta.breadcrumb)?.meta.breadcrumb
if (!routeNames) {
breadcrumbRoutes.value = []
return
}
const allRoutes = router.getRoutes()
breadcrumbRoutes.value = routeNames.map((name) => {
const route = allRoutes.find((route) => route.name === name)
if (!route) {
throw Error(
`Route ${name}, declared in breadcrumd, cannot be found in routes.`,
)
}
return {
name: route.name,
params: to.params,
args: route.meta.args || {},
}
})
updateHtmlTitle()
})
// INIT
async function _checkInstall(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
try {
const data = await timeout(
api.get<{ installed: boolean }>('installed'),
5000,
)
installed.value = data.installed
} catch (err) {
if (retry > 0) {
return _checkInstall(--retry)
}
throw err
}
}
async function onAppCreated() {
await _checkInstall()
if (!installed.value) {
router.push({ name: 'post-install' })
} else {
await _onLogin()
}
}
function getYunoHostVersion() {
return api.get('versions').then((versions) => {
yunohost.value = versions.yunohost
})
}
// CONNECTION
async function _onLogin() {
// If the user is not connected, the first action will throw
// and login prompt will be shown automaticly
await getYunoHostVersion()
connected.value = true
await api.get({ uri: 'domains', cachePath: 'domains' })
}
function onLogout(route?: RouteLocationNormalizedLoaded) {
connected.value = false
yunohost.value = undefined
const previousRoute = route ?? router.currentRoute.value
if (previousRoute.name === 'login') return
router.push({
name: 'login',
// Add a redirect query if next route is not unknown (like `logout`) or `login`
query:
previousRoute && !['login', null].includes(previousRoute.name as any)
? { redirect: previousRoute.path }
: {},
})
}
function login(credentials: string) {
return api
.post({ uri: 'login', data: { credentials }, websocket: false })
.then(() => _onLogin())
}
function logout() {
onLogout()
return api.get('logout')
}
function tryToReconnect(args?: ReconnectingArgs) {
useRequests().reconnecting.value = args
}
function updateRouterKey(to?: RouteLocationNormalized) {
if (!to) {
// Trick to force a view reload
routerKey.value += '0'
return
}
// If the next route uses the same component as the previous one, Vue will not
// recreate an instance of that component, so hooks like `created()` will not be
// triggered and data will not be fetched.
// For routes with params, we create a unique key to force the recreation of a view.
// Params can be declared in route `meta` to stricly define which params should be
// taken into account.
const params = to.meta.routerParams
? to.meta.routerParams.map((key) => to.params[key])
: Object.values(to.params)
routerKey.value = `${to.name?.toString()}-${params.join('-')}`
}
function updateHtmlTitle() {
// Display a simplified breadcrumb as the document title.
document.title = `${htmlTitle.value} | ${i18n.global.t('yunohost_admin')}`
}
return {
host,
installed,
connected,
yunohost,
routerKey,
breadcrumb,
ssoLink,
onAppCreated,
getYunoHostVersion,
onLogout,
login,
logout,
tryToReconnect,
updateHtmlTitle,
updateRouterKey,
}
})

View file

@ -0,0 +1,209 @@
import { createGlobalState } from '@vueuse/core'
import { v4 as uuid } from 'uuid'
import { computed, reactive, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import type { APIQuery, RequestMethod } from '@/api/api'
import { APIErrorLog, type APIError } from '@/api/errors'
import { isObjectLiteral } from '@/helpers/commons'
import i18n from '@/i18n'
import type { StateVariant } from '@/types/commons'
import { useInfos } from './useInfos'
export type RequestStatus = 'pending' | 'success' | 'warning' | 'error'
export type APIRequest = {
status: RequestStatus
method: RequestMethod
uri: string
id: string
humanRoute: string
initial: boolean
date: number
err?: APIError
action?: APIActionProps
showModal?: boolean
showModalTimeout?: number
}
type APIActionProps = {
messages: RequestMessage[]
errors: number
warnings: number
progress?: number[]
}
export type APIRequestAction = APIRequest & {
action: APIActionProps
}
export type RequestMessage = {
text: string
variant: StateVariant
}
export type ReconnectingArgs = {
attemps?: number
origin?: string
initialDelay?: number
delay?: number
}
export const useRequests = createGlobalState(() => {
const router = useRouter()
const requests = shallowRef<APIRequest[]>([])
const reconnecting = ref<ReconnectingArgs | undefined>()
const currentRequest = computed(() => {
return requests.value.find((r) => r.showModal)
})
const locked = computed(() => currentRequest.value?.showModal)
const historyList = computed<APIRequestAction[]>(() => {
return requests.value
.filter((r) => !!r.action || !!r.err)
.reverse() as APIRequestAction[]
})
function startRequest({
uri,
method,
humanKey,
initial,
websocket,
showModal,
}: {
uri: string
method: RequestMethod
humanKey?: APIQuery['humanKey']
showModal: boolean
websocket: boolean
initial: boolean
}): APIRequest {
// 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.global.t(`human_routes.${key}`, args)
: `[${method}] /${uri.split('?')[0]}`
const request: APIRequest = reactive({
method,
uri,
status: 'pending',
humanRoute,
initial,
showModal: false,
id: uuid(),
date: Date.now(),
err: undefined,
action: websocket
? {
messages: [],
warnings: 0,
errors: 0,
}
: undefined,
})
requests.value = [...requests.value, request]
const r = requests.value[requests.value.length - 1]!
if (showModal) {
request.showModalTimeout = window.setTimeout(() => {
// Display the waiting modal only if the request takes some time.
if (r.status === 'pending') {
r.showModal = true
}
}, 300) as unknown as number
}
return r
}
function endRequest({
request,
success,
isFormError = false,
}: {
request: APIRequest
success: boolean
isFormError?: boolean
}) {
let status: RequestStatus = success ? 'success' : 'error'
let hideModal = success || isFormError
if (success && request.action) {
const { warnings, errors, messages } = request.action
const msgCount = messages.length
if (msgCount && messages[msgCount - 1].variant === 'warning') {
hideModal = false
}
if (errors || warnings) status = 'warning'
}
if (request.showModalTimeout) {
// Clear the timeout to avoid delayed modal to show up
clearTimeout(request.showModalTimeout)
delete request.showModalTimeout
}
setTimeout(() => {
request.status = status
if (request.showModal && hideModal) {
request.showModal = false
// We can remove requests that are not actions or has no errors
requests.value = requests.value.filter(
(r) => r.showModal || !!r.action || !!r.err,
)
}
}, 350)
}
function handleAPIError(err: APIError) {
err.log()
if (err.code === 401) {
// Unauthorized
useInfos().onLogout()
} else if (err instanceof APIErrorLog) {
// Errors that have produced logs
router.push({ name: 'tool-log', params: { name: err.logRef } })
} else {
const request = requests.value.find((r) => r.id === err.requestId)!
request.err = err
}
}
function showModal(requestId: APIRequest['id']) {
const request = requests.value.find((r) => r.id === requestId)!
request.showModal = true
}
function dismissModal(requestId: APIRequest['id']) {
const request = requests.value.find((r) => r.id === requestId)!
if (request.err && request.initial) {
// In case of an initial request (data that is needed by a view to render itself),
// try to go back so the user doesn't get stuck at a never ending skeleton view.
if (history.length > 2) {
history.back()
} else {
// if the url was opened in a new tab, return to home
router.push({ name: 'home' })
}
}
request.showModal = false
}
return {
requests,
historyList,
currentRequest,
reconnecting,
locked,
startRequest,
endRequest,
handleAPIError,
dismissModal,
showModal,
}
})

View file

@ -0,0 +1,50 @@
import type {
ComputedRef,
MaybeRefOrGetter,
Ref,
WritableComputedRef,
} from 'vue'
import { computed, isRef, ref, toValue } from 'vue'
import type { AnyTreeNode, TreeRootNode } from '@/helpers/data/tree'
// Returns `undefined` when there's no items and `null` when there's no match
export function useSearch<
T extends any[] | TreeRootNode,
V extends T extends (infer V)[] ? V : AnyTreeNode,
>(
items: MaybeRefOrGetter<T> | ComputedRef<T>,
filterFn: (search: string, item: V, index: number, arr: T) => boolean,
{
externalSearch,
filterIfNoSearch = false,
filterAllFn,
}: {
filterAllFn?: (search: string, items: T) => boolean | undefined
filterIfNoSearch?: boolean
externalSearch?: Ref<string> | WritableComputedRef<string>
} = {},
): [search: Ref<string>, filteredItems: ComputedRef<T | undefined | null>] {
const search = isRef(externalSearch)
? externalSearch
: ref(toValue(externalSearch) ?? '')
const filteredItems = computed(() => {
const items_ = toValue(items)
const s = toValue(search.value).toLowerCase()
if (!items_.length) return undefined
if (filterAllFn) {
const returnAll = filterAllFn(s, items_)
if (returnAll !== undefined) {
return returnAll ? items_ : undefined
}
}
if (!s && !filterIfNoSearch) return items_
const filteredItems_ = items_.filter((...args) =>
filterFn(s, ...(args as [V, number, T])),
) as T
return filteredItems_.length ? filteredItems_ : null
})
return [search, filteredItems]
}

View file

@ -0,0 +1,75 @@
import {
createGlobalState,
useLocalStorage,
watchImmediate,
} from '@vueuse/core'
import { ref } from 'vue'
import {
getDefaultLocales,
setI18nFallbackLocale,
setI18nLocale,
} from '@/i18n/helpers'
import type { SupportedLocales } from '@/i18n/supportedLocales'
import supportedLocales from '@/i18n/supportedLocales'
import type { RouteFromTo } from '@/types/commons'
export const useSettings = createGlobalState(() => {
const navigatorLocales = getDefaultLocales()
const localesLoaded = ref(false)
const locale = useLocalStorage<SupportedLocales>(
'locale',
navigatorLocales[0],
)
const fallbackLocale = useLocalStorage<SupportedLocales>(
'fallbackLocale',
navigatorLocales[1],
)
const cache = useLocalStorage('cache', true)
const transitions = useLocalStorage('transitions', true)
const dark = useLocalStorage('dark', false)
const experimental = useLocalStorage('experimental', false)
const spinner = ref('pacman')
const transitionName = ref<'slide-right' | 'slide-left' | undefined>()
watchImmediate([locale, fallbackLocale], async (next, prev) => {
if (next[0] !== prev[0]) await setI18nLocale(next[0])
if (next[1] !== prev[1]) await setI18nFallbackLocale(next[1])
localesLoaded.value = true
})
watchImmediate(dark, (dark) => {
document.documentElement.setAttribute(
'data-bs-theme',
dark ? 'dark' : 'light',
)
})
function updateTransitionName({ to, from }: RouteFromTo) {
// Use the breadcrumb array length as a direction indicator
const toDepth = (to.meta.breadcrumb || []).length
const fromDepth = (from.meta.breadcrumb || []).length
transitionName.value = toDepth < fromDepth ? 'slide-right' : 'slide-left'
}
return {
localesLoaded,
locale,
fallbackLocale,
cache,
transitions,
dark,
experimental,
spinner,
transitionName,
availableLocales: Object.entries(supportedLocales).map(
([locale, { name }]) => {
return { value: locale, text: name }
},
),
updateTransitionName,
}
})

View file

@ -1,137 +0,0 @@
/**
* Allow to set a timeout on a `Promise` expected response.
* The returned Promise will be rejected if the original Promise is not resolved or
* rejected before the delay.
*
* @param {Promise} promise - A promise (like a fetch call).
* @param {Number} delay - delay after which the promise is rejected
* @return {Promise}
*/
export function timeout(promise, delay) {
return new Promise((resolve, reject) => {
// FIXME reject(new Error('api_not_responding')) for post-install
setTimeout(() => reject, delay)
promise.then(resolve, reject)
})
}
/**
* 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)
)
}
/**
* Check if value is "empty" (`null`, `undefined`, `''`, `[]`, '{}').
* Note: `0` is not considered "empty" in that helper.
*
* @param {*} value - Anything.
* @return {Boolean}
*/
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.
*
* @param {Object} obj - An object literal to flatten.
* @param {Object} [flattened={}] - An object literal to add passed obj keys/values.
* @return {Object}
*/
export function flattenObjectLiteral(obj, flattened = {}) {
function flatten(objLit) {
for (const key in objLit) {
const value = objLit[key]
if (isObjectLiteral(value)) {
flatten(value)
} else {
flattened[key] = value
}
}
}
flatten(obj)
return flattened
}
/**
* Returns an new Object filtered with passed filter function.
* Each entry `[key, value]` will be forwarded to the `filter` function.
*
* @param {Object} obj - object to filter.
* @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)),
)
}
/**
* Returns an new array containing items that are in first array but not in the other.
*
* @param {Array} [arr1=[]]
* @param {Array} [arr2=[]]
* @return {Array}
*/
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) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* Returns a random integer between `min` and `max`.
*
* @param {Number} min
* @param {Number} max
* @return {Number}
*/
export function randint(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
/**
* Returns a File content.
*
* @param {File} file
* @param {Object} [extraParams] - Optionnal params
* @param {Boolean} [extraParams.base64] - returns a base64 representation of the file.
* @return {Promise<String>}
*/
export function getFileContent(file, { base64 = false } = {}) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = reject
reader.onload = () => resolve(reader.result)
if (base64) {
reader.readAsDataURL(file)
} else {
reader.readAsText(file)
}
})
}

182
app/src/helpers/commons.ts Normal file
View file

@ -0,0 +1,182 @@
import i18n from '@/i18n'
import type { Obj } from '@/types/commons'
/**
* Allow to set a timeout on a `Promise` expected response.
* The returned Promise will be rejected if the original Promise is not resolved or
* rejected before the delay.
*
* @param promise - A promise (like a fetch call).
* @param delay - delay after which the promise is rejected
*/
export function timeout<T extends unknown>(
promise: Promise<T>,
delay: number,
): Promise<T> {
return new Promise((resolve, reject) => {
// FIXME reject(new Error('api_not_responding')) for post-install
setTimeout(() => reject, delay)
promise.then(resolve, reject)
})
}
/**
* Check if passed value is an object literal.
*
* @param value - Anything.
*/
export function isObjectLiteral(value: any): value is Obj {
return (
value !== null &&
value !== undefined &&
Object.is(value.constructor, Object)
)
}
export function objectGet<
T extends Obj,
K extends keyof T | string,
F extends any = undefined,
>(obj: T, key: K, fallback?: F) {
return (key in obj ? obj[key] : fallback) as K extends keyof T ? T[K] : F
}
/**
* Check if value is "empty" (`null`, `undefined`, `''`, `[]`, '{}').
* Note: `0` is not considered "empty" in that helper.
*
* @param value - Anything.
*/
export function isEmptyValue(
value: any,
): value is null | undefined | '' | [] | {} {
if (typeof value === 'number' || typeof value === 'boolean') return false
return (
!value ||
(Array.isArray(value) && value.length === 0) ||
Object.keys(value).length === 0
)
}
/**
* Returns an new Object filtered with passed filter function.
* Each entry `[key, value]` will be forwarded to the `filter` function.
*
* @param obj - object to filter.
* @param filter - the filter function to call for each entry.
*/
export function filterObject<T extends Obj>(
obj: T,
filter: (
entries: [string, any],
index: number,
array: [string, any][],
) => boolean,
) {
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.
*/
export function arrayDiff<T extends string>(
arr1: T[] = [],
arr2: T[] = [],
): T[] {
return arr1.filter((item) => !arr2.includes(item))
}
export function joinOrNull(
value: any[] | string | null | undefined,
): string | null {
if (Array.isArray(value) && value.length) {
return value.join(i18n.global.t('words.separator'))
}
return typeof value === 'string' ? value : null
}
/**
* Returns a new string with escaped HTML (`&<>"'` replaced by entities).
*
* @param unsafe - string to escape
*/
export function escapeHtml(unsafe: string) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* Returns a random integer between `min` and `max`.
*
* @param min - min possible value
* @param max - max possible value
*/
export function randint(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
/**
* Returns a File content.
*
* @param file -
* @param base64 - returns a base64 representation of the file.
*/
export function getFileContent(
file: File,
{ base64 = false } = {},
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = reject
reader.onload = () => resolve(reader.result as string)
if (base64) {
reader.readAsDataURL(file)
} else {
reader.readAsText(file)
}
})
}
export function getKeys<T extends Obj, K extends (keyof T)[]>(obj: T): K {
return Object.keys(obj) as K
}
export function toEntries<T extends Record<PropertyKey, unknown>>(
obj: T,
): { [K in keyof T]: [K, T[K]] }[keyof T][] {
return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][]
}
export function fromEntries<
const T extends ReadonlyArray<readonly [PropertyKey, unknown]>,
>(entries: T): { [K in T[number] as K[0]]: K[1] } {
return Object.fromEntries(entries) as { [K in T[number] as K[0]]: K[1] }
}
export function pick<T extends Obj, K extends (keyof T)[]>(
obj: T,
keys: K,
): Pick<T, K[number]> {
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<
T,
K[number]
>
}
export function omit<T extends Obj, K extends (keyof T)[]>(
obj: T,
keys: K,
): Omit<T, K[number]> {
return Object.fromEntries(
Object.keys(obj)
.filter((key) => !keys.includes(key))
.map((key) => [key, obj[key]]),
) as Omit<T, K[number]>
}

View file

@ -1,15 +1,23 @@
import type { RouteLocationRaw } from 'vue-router'
type TreeNodeData = {
name: string
parent: string | null
to: RouteLocationRaw
opened: boolean
}
/**
* A Node that can have a parent and children.
*/
export class Node {
constructor(data) {
this.data = data
this.depth = 0
this.height = 0
this.parent = null
// this.id = null
// this.children = null
}
class TreeNode {
data: TreeNodeData | null = null
depth: number = 0
height: number = 0
parent: AnyTreeNode | null = null
id: string = 'root'
children: TreeChildNode[] = []
_remove: boolean = false
/**
* Invokes the specified `callback` for this node and each descendant in pre-order
@ -18,17 +26,17 @@ export class Node {
* The specified function is passed the current descendant, the zero-based traversal
* index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachBefore.js.
*
* @param {function} callback
* @return {Object}
*/
eachBefore(callback) {
const nodes = []
eachBefore(
callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => void,
) {
const root = this as TreeRootNode
const nodes: AnyTreeNode[] = []
let index = -1
let node = this
let node: AnyTreeNode | undefined = root
while (node) {
callback(node, ++index, this)
callback(node, ++index, root)
if (node.children) {
nodes.push(...node.children)
}
@ -45,14 +53,14 @@ export class Node {
* The specified function is passed the current descendant, the zero-based traversal
* index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachAfter.js.
*
* @param {function} callback
* @return {Object}
*/
eachAfter(callback) {
const nodes = []
const next = []
let node = this
eachAfter(
callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => void,
) {
const root = this as TreeRootNode
const nodes: AnyTreeNode[] = []
const next: AnyTreeNode[] = []
let node: AnyTreeNode | undefined = root
while (node) {
next.push(node)
@ -64,132 +72,117 @@ export class Node {
let index = 0
for (let i = next.length - 1; i >= 0; i--) {
callback(next[i], index++, this)
callback(next[i], index++, root)
}
return this
return root
}
/**
* Returns a deep copied and filtered tree of itself.
* Specified filter function is passed each nodes in post-order traversal and must
* return `true` or `false` like a regular filter function.
*
* @param {Function} callback - filter callback function to invoke on each nodes
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node}
*/
filter(callback) {
filter(
callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => boolean,
) {
const root = this as TreeRootNode
// Duplicates this tree and iter on nodes from leaves to root (post-order traversal)
return hierarchy(this).eachAfter((node, i) => {
return hierarchy(root).eachAfter((node, i) => {
// Since we create a new hierarchy from another, nodes's `data` contains the
// whole dupplicated node. Overwrite node's `data` by node's original `data`.
node.data = node.data.data
if (node.children) {
// Removed flagged children
node.children = node.children.filter((child) => !child.remove)
if (!node.children.length) delete node.children
node.children = node.children.filter((child) => !child._remove)
}
// Perform filter callback on non-root nodes
const match = node.data ? callback(node, i, this) : true
const match =
node instanceof TreeChildNode ? callback(node, i, root) : true
// Flag node if there's no match in node nor in its children
if (!match && !node.children) {
node.remove = true
if (!match && !node.children.length) {
node._remove = true
}
})
}
get length(): number {
return this.children.length
}
}
export class TreeRootNode extends TreeNode {
data: null = null
parent: null = null
id: 'root' = 'root'
}
export class TreeChildNode extends TreeNode {
data: TreeNodeData
parent: AnyTreeNode
id: string
constructor(data: TreeNodeData, parent: AnyTreeNode) {
super()
this.data = data
this.parent = parent
this.id = data.name
}
}
export type AnyTreeNode = TreeRootNode | TreeChildNode
/**
* Generates a new hierarchy from the specified tabular `dataset`.
* The specified `dataset` must be an array of objects that contains at least a
* `name` property and an optional `parent` property referencing its parent `name`.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js#L16.
*
* @param {Array} dataset
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @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' } = {},
) {
const root = new Node(null, true)
root.children = []
const nodesMap = new Map()
export function stratify(dataset: TreeNodeData[]) {
const root = new TreeRootNode()
const nodesMap: Map<TreeChildNode['id'], TreeChildNode> = new Map()
// Creates all nodes that will be arranged in a hierarchy
const nodes = dataset.map((d) => {
const node = new Node(d)
node.id = d[idKey]
dataset.map((d) => {
const parent = d.parent ? nodesMap.get(d.parent) || root : root
const node = new TreeChildNode(d, parent)
parent.children.push(node)
nodesMap.set(node.id, node)
if (d[parentIdKey]) {
node.parent = d[parentIdKey]
}
return node
})
// Build a hierarchy from nodes
nodes.forEach((node, i) => {
const parentId = node.parent
if (parentId) {
const parent = nodesMap.get(parentId)
if (!parent) throw new Error('Missing parent node: ' + parentId)
if (parent.children) parent.children.push(node)
else parent.children = [node]
node.parent = parent
} else {
node.parent = root
root.children.push(node)
}
})
root.eachBefore((node) => {
// Compute node depth
if (node.parent) {
node.depth = node.parent.depth + 1
// Remove parent key if parent is root (node with no data)
if (!node.parent.data) delete node.parent
}
computeNodeHeight(node)
})
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.
* If given a `Node` object this will return a deep copy of it.
* If given a `TreeRootNode` object this will return a deep copy of it.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L15.
*
* @param {Node|Object} data - object representing a root node (a simple { id, children } object or a `Node`)
* @return {Node}
* @param data - object representing a root node (a simple { id, children } object or a `TreeNode`)
*/
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_) => {
const child = new Node(child_)
child.id = child_.id
child.parent = node === root ? null : node
child.depth = node.depth + 1
nodes.push(child)
return child
})
}
node = nodes.pop()
export function hierarchy(data: TreeRootNode) {
function deepCopyNodes(nodes: TreeChildNode[], parent: AnyTreeNode) {
return nodes.map((node) => {
const copy = new TreeChildNode(node.data, parent)
copy.depth = parent.depth + 1
copy.children = deepCopyNodes(node.children, copy)
return copy
})
}
const root = new TreeRootNode()
root.children = deepCopyNodes(data.children, root)
root.eachBefore(computeNodeHeight)
return root
}
@ -197,13 +190,12 @@ export function hierarchy(data) {
/**
* 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: TreeNode) {
let node_: TreeNode | null = node
let height = 0
do {
node.height = height
node = node.parent
} while (node && node.height < ++height)
node_.height = height
node_ = node_.parent
} while (node_ && node_.height < ++height)
}

View file

@ -1,15 +0,0 @@
import formatDistanceToNow from 'date-fns/formatDistanceToNow'
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 readableDate(date, isTimestamp = false) {
return format(new Date(isTimestamp ? date * 1000 : date), 'PPPpp', { locale })
}

View file

@ -0,0 +1,21 @@
import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'
import { format } from 'date-fns/format'
import { dateFnsLocale as locale } from '@/i18n/helpers'
export function distanceToNow(
date: string | number,
addSuffix = true,
isTimestamp = false,
) {
const tsOrDate = isTimestamp && typeof date === 'number' ? date * 1000 : date
return formatDistanceToNow(new Date(tsOrDate), { addSuffix, locale })
}
export function readableDate(
date: string | number,
isTimestamp = false,
): string {
const tsOrDate = isTimestamp && typeof date === 'number' ? date * 1000 : date
return format(new Date(tsOrDate), 'PPPpp', { locale })
}

View file

@ -1,15 +0,0 @@
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()
})
}

View file

@ -0,0 +1,16 @@
export function humanSize(bytes: string | number) {
const b = typeof bytes === 'string' ? parseFloat(bytes) : bytes
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
if (bytes === 0) return 'n/a'
const i = Math.floor(Math.log(b) / Math.log(1024))
return (b / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]
}
export function humanPermissionName(text: string) {
return text
.split('.')[1]
.replace('_', ' ')
.replace(/\w\S*/g, (part) => {
return part.charAt(0).toUpperCase() + part.substr(1).toLowerCase()
})
}

View file

@ -1,70 +1,60 @@
import { helpers } from 'vuelidate/lib/validators'
import { helpers } from '@vuelidate/validators'
import { toValue, type MaybeRef } from 'vue'
// FIXME no typing, but the lib is currently not actively maintained
// so it's propably better not to spend time on it.
// 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 alphalownumdot_ = helpers.regex('alphalownumdot_', /^[a-z0-9_.]+$/)
const alphalownumdot_ = helpers.regex(/^[a-z0-9_.]+$/)
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 emailLocalPart = helpers.regex('emailLocalPart', /^[\w+.-]+$/)
const emailLocalPart = helpers.regex(/^[\w+.-]+$/)
const emailForwardLocalPart = helpers.regex(
'emailForwardLocalPart',
/^[\w+.-]+$/,
)
const emailForwardLocalPart = helpers.regex(/^[\w+.-]+$/)
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)
const email = (value: string) => {
const [localPart, domainPart] = value.split('@')
if (!domainPart) return !helpers.req(value) || false
return (
!helpers.req(value) || (emailLocalPart(localPart) && domain(domainPart))
)
}
// Same as email but with `+` allowed.
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)
const emailForward = (value: string) => {
const [localPart, domainPart] = value.split('@')
if (!domainPart) return !helpers.req(value) || false
return (
!helpers.req(value) ||
(emailForwardLocalPart(localPart) && domain(domainPart))
)
}
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 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: MaybeRef<any[] | null>) =>
helpers.withParams({ type: 'unique', arg: toValue(items) }, (item) => {
const items_ = toValue(items)
return items_ ? !helpers.req(item) || !items_.includes(item) : true
})
export {
alphalownumdot_,
@ -75,7 +65,6 @@ export {
emailForwardLocalPart,
emailLocalPart,
appRepoUrl,
includes,
name,
unique,
}

View file

@ -9,4 +9,4 @@ export {
minValue,
required,
sameAs,
} from 'vuelidate/lib/validators'
} from '@vuelidate/validators'

View file

@ -1,563 +0,0 @@
import i18n from '@/i18n'
import store from '@/store'
import evaluate from 'simple-evaluate'
import * as validators from '@/helpers/validators'
import {
isObjectLiteral,
isEmptyValue,
flattenObjectLiteral,
getFileContent,
} from '@/helpers/commons'
const NO_VALUE_FIELDS = [
'ReadOnlyField',
'ReadOnlyAlertItem',
'MarkdownItem',
'DisplayTextItem',
'ButtonItem',
]
export const DEFAULT_STATUS_ICON = {
[null]: null,
danger: 'times',
error: 'times',
info: 'info',
success: 'check',
warning: 'warning',
}
/**
* Tries to find a translation corresponding to the user's locale/fallback locale in a
* Yunohost argument or simply return the string if it's not an object literal.
*
* @param {(Object|String|undefined)} field - A field value containing a translation object or string
* @return {String}
*/
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) {
const unit = sizeStr.slice(-1)
const value = sizeStr.slice(0, -1)
if (unit === 'M') return parseInt(value)
if (unit === 'b') return Math.ceil(value / (1024 * 1024))
if (unit === 'k') return Math.ceil(value / 1024)
if (unit === 'G') return Math.ceil(value * 1024)
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) {
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.
*
* @param {String} expression - A String to evaluate.
* @param {Object} forms - A nested form used in config panels.
* @return {Boolean} - expression evaluation result.
*/
export function evaluateExpression(expression, form, nested = true) {
if (!expression) return true
if (expression === '"false"') return false
const context = nested
? Object.values(form).reduce((merged, next) => ({ ...merged, ...next }))
: form
for (const key in context) {
if (isObjectLiteral(context[key]) && 'file' in context[key]) {
context[key] = context[key].content
}
}
// Allow to use match(var,regexp) function
const matchRe = /match(\s*(\w+)\s*,\s*"([^"]+)"\s*)/g
for (const matched of expression.matchAll(matchRe)) {
const [fullMatch, varMatch, regExpMatch] = matched
const varName = varMatch + '__re' + matched.index
context[varName] = new RegExp(regExpMatch, 'm').test(context[varMatch])
expression = expression.replace(fullMatch, varName)
}
try {
return !!evaluate(context, expression)
} catch {
return false
}
}
// Adds a property to an Object that will dynamically returns a expression evaluation result.
function addEvaluationGetter(prop, obj, expr, ctx, nested) {
Object.defineProperty(obj, prop, {
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.
*
* @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 : null
const validation = {}
const error = { message: null }
arg.ask = formatI18nField(arg.ask)
const field = {
is: arg.readonly ? 'ReadOnlyField' : 'FormField',
visible: arg.visible,
props: {
label: arg.ask,
component: undefined,
props: {},
},
}
const defaultProps = ['id', 'placeholder:example']
const components = [
{
types: ['string', 'path'],
name: 'InputItem',
props: defaultProps.concat(['autocomplete', 'trim', 'choices']),
},
{
types: ['email', 'url', 'date', 'time', 'color'],
name: 'InputItem',
props: defaultProps.concat(['type', 'trim']),
},
{
types: ['password'],
name: 'InputItem',
props: defaultProps.concat(['type', 'autocomplete', 'trim']),
callback: function () {
if (!arg.help) {
arg.help = i18n.t('good_practices_about_admin_password')
}
arg.example = '••••••••••••'
validation.passwordLenght = validators.minLength(8)
},
},
{
types: ['number', 'range'],
name: 'InputItem',
props: defaultProps.concat(['type', 'min', 'max', 'step']),
callback: function () {
if (arg.min !== undefined) {
validation.minValue = validators.minValue(arg.min)
}
if (arg.max !== undefined) {
validation.maxValue = validators.maxValue(arg.max)
}
validation.numValue = validators.integer
},
},
{
types: ['select', 'user', 'domain', 'app', 'group'],
name: 'SelectItem',
props: ['id', 'choices'],
callback: function () {
if (arg.type !== 'select') {
field.props.link = {
name: arg.type + '-list',
text: i18n.t(`manage_${arg.type}s`),
}
}
},
},
{
types: ['file'],
name: 'FileItem',
props: defaultProps.concat(['accept']),
callback: function () {
value = {
// in case of already defined file, we receive only the file path (not the actual file)
file: value ? new File([''], value) : null,
content: '',
current: !!value,
removed: false,
}
},
},
{
types: ['text'],
name: 'TextAreaItem',
props: defaultProps,
},
{
types: ['tags'],
name: 'TagsItem',
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,
})
}
if (typeof value === 'string') {
value = value.split(',')
} else if (!value) {
value = []
}
},
},
{
types: ['boolean'],
name: 'CheckboxItem',
props: ['id', 'choices'],
callback: function () {
if (value !== null && value !== undefined) {
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(),
)
}
},
},
{
types: ['alert'],
name: 'ReadOnlyAlertItem',
props: ['type:style', 'label:ask', 'icon'],
renderSelf: true,
},
{
types: ['markdown'],
name: 'MarkdownItem',
props: ['label:ask'],
renderSelf: true,
},
{
types: ['display_text'],
name: 'DisplayTextItem',
props: ['label:ask'],
renderSelf: true,
},
{
types: ['button'],
name: 'ButtonItem',
props: ['type:style', 'label:ask', 'icon', 'enabled'],
renderSelf: true,
},
]
// Default type management if no one is filled
if (arg.type !== 'tags' && arg.choices && arg.choices.length) {
arg.type = 'select'
} else if (arg.type === undefined) {
arg.type = 'string'
}
// Search the component bind to the 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
if (component.callback) component.callback()
field.props.component = component.name
// Affect properties to the field Item
for (let prop of component.props) {
prop = prop.split(':')
const propName = prop[0]
const argName = prop.slice(-1)[0]
if (argName in arg) {
field.props.props[propName] = arg[argName]
}
}
// Required (no need for checkbox its value can't be null)
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),
)
}
if (!component.renderSelf && !arg.readonly) {
// Bind a validation with what the server may respond
validation.remote = validators.helpers.withParams(error, (v) => {
const result = !error.message
error.message = null
return result
})
}
// Default value if still `null`
if (value === null && arg.default) {
value = arg.default
}
// Help message
if (arg.help) {
field.props.description = formatI18nField(arg.help)
}
// Help message
if (arg.helpLink) {
field.props.link = {
href: arg.helpLink.href,
text: i18n.t(arg.helpLink.text),
}
}
if (component.renderSelf) {
field.is = field.props.component
field.props = field.props.props
}
return {
value,
field,
// Return null instead of empty object if there's no validation
validation: Object.keys(validation).length === 0 ? null : validation,
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.
*
* @param {Array} args - a yunohost arg array written by a packager.
* @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) {
const form = {}
const fields = {}
const validations = {}
const errors = {}
for (const arg of args) {
const { value, field, validation, error } = formatYunoHostArgument(arg)
fields[arg.id] = field
form[arg.id] = value
if (validation) validations[arg.id] = validation
errors[arg.id] = error
if ('visible' in arg && typeof arg.visible === 'string') {
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,
)
}
}
return { form, fields, validations, errors }
}
export function formatYunoHostConfigPanels(data) {
const result = {
panels: [],
forms: {},
validations: {},
errors: {},
}
for (const { id: panelId, name, help, sections } of data.panels) {
const panel = {
id: panelId,
sections: [],
serverError: '',
hasApplyButton: false,
}
result.forms[panelId] = {}
result.validations[panelId] = {}
result.errors[panelId] = {}
if (name) panel.name = formatI18nField(name)
if (help) panel.help = formatI18nField(help)
for (const _section of sections) {
const section = {
id: _section.id,
isActionSection: _section.is_action_section,
visible: _section.visible,
}
if (_section.help) section.help = formatI18nField(_section.help)
if (_section.name) section.name = formatI18nField(_section.name)
if (typeof _section.visible === 'string') {
addEvaluationGetter('visible', section, section.visible, 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)
Object.assign(result.errors[panelId], errors)
section.fields = fields
panel.sections.push(section)
if (
!section.isActionSection &&
Object.values(fields).some(
(field) => !NO_VALUE_FIELDS.includes(field.is),
)
) {
panel.hasApplyButton = true
}
}
result.panels.push(panel)
}
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
* objects must be merged to define the final sent form.
*
* Convert Boolean to '1' (true) or '0' (false),
* Concatenate two parts adresses (subdomain or email for example) into a single string,
* Convert File to its Base64 representation or set its value to '' to ask for a removal.
*
* @param {*} value
* @return {*}
*/
export function formatFormDataValue(value, key = null) {
if (Array.isArray(value)) {
return Promise.all(value.map((value_) => formatFormDataValue(value_))).then(
(resolvedValues) => ({ [key]: resolvedValues }),
)
}
let result = value
if (typeof value === 'boolean') result = value ? 1 : 0
if (isObjectLiteral(value) && 'file' in value) {
// File has to be deleted
if (value.removed) result = ''
// 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 {
[key]: content.replace(/data:[^;]*;base64,/, ''),
[key + '[name]']: value.file.name,
}
})
}
} else if (isObjectLiteral(value) && 'separator' in value) {
result = Object.values(value).join('')
}
// Returns a resolved Promise for non async values
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
* make sure every async values resolves before resolving itself.
*
* @param {Object} formData
* @return {Object}
*/
function formatFormDataValues(formData) {
const promisedValues = Object.entries(formData).map(([key, value]) => {
return formatFormDataValue(value, key)
})
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.
*
* @param {Object} formData - An object literal containing form values.
* @param {Object} [extraParams] - Optionnal params
* @param {Array} [extraParams.extract] - An array of keys that should be extracted from the form.
* @param {Boolean} [extraParams.flatten=false] - Flattens or not the passed 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(
formData,
{
extract = null,
flatten = false,
removeEmpty = true,
removeNull = false,
} = {},
) {
const output = {
data: {},
extracted: {},
}
const values = await formatFormDataValues(formData)
for (const key in values) {
const type = extract && extract.includes(key) ? 'extracted' : 'data'
const value = values[key]
if (removeEmpty && isEmptyValue(value)) {
continue
} else if (removeNull && [null, undefined].includes(value)) {
continue
} else if (flatten && isObjectLiteral(value)) {
flattenObjectLiteral(value, output[type])
} else {
output[type][key] = value
}
}
const { data, extracted } = output
return extract ? { data, ...extracted } : data
}

View file

@ -0,0 +1,256 @@
import { toValue, type MaybeRef } from 'vue'
import { useSettings } from '@/composables/useSettings'
import {
getFileContent,
isEmptyValue,
isObjectLiteral,
toEntries,
} from '@/helpers/commons'
import type {
ArrInnerType,
Obj,
StateStatus,
Translation,
} from '@/types/commons'
import type { AdressModelValue, FileModelValue } from '@/types/form'
import { isAdressModelValue, isFileModelValue } from '@/types/form'
export const STATUS_VARIANT = {
pending: 'primary',
success: 'success',
warning: 'warning',
error: 'danger',
info: 'info',
} as const
export const DEFAULT_VARIANT_ICON = {
primary: null,
secondary: null,
success: 'check',
danger: 'times',
warning: 'warning',
info: 'info',
light: null,
dark: null,
best: null,
} as const
export function isOkStatus(status: StateStatus): status is 'info' | 'success' {
return ['info', 'success'].includes(status)
}
// FORMAT FROM CORE
/**
* Tries to find a translation corresponding to the user's locale/fallback locale in a
* Yunohost argument or simply return the string if it's not an object literal.
*
* @param field - A field value containing a translation object or string
* @return translated field or empty string
*/
export function formatI18nField(field?: Translation): string {
if (!field) return ''
if (typeof field === 'string') return field
const { locale, fallbackLocale } = useSettings()
return field[locale.value] || field[fallbackLocale.value] || field.en || ''
}
/**
* Returns a string size declaration to a M value.
*
* @param size - A size declared like '500M' or '56k'
* @return a number in M
*/
export function sizeToM(size: string) {
const unit = size.slice(-1)
const value = parseInt(size.slice(0, -1))
if (unit === 'M') return value
if (unit === 'b') return Math.ceil(value / (1024 * 1024))
if (unit === 'k') return Math.ceil(value / 1024)
if (unit === 'G') return Math.ceil(value * 1024)
if (unit === 'T') return Math.ceil(value * 1024 * 1024)
}
/**
* Returns an address as AdressModelValue to be used by AdressItem component.
*
* @param address - A string representing an adress (subdomain or email)
* @return Parsed address as `AdressModelValue`
*/
export function formatAdress(address: string): AdressModelValue {
const separator = address.includes('@') ? '@' : '.'
const [localPart, domain] = address.split(separator)
return { localPart, separator, domain }
}
// FORMAT TO CORE
type BasePossibleFormValues =
| FileModelValue
| AdressModelValue
| boolean
| string
| number
| null
| undefined
type PossibleFormValues = BasePossibleFormValues | BasePossibleFormValues[]
/**
* Parse a front-end value to its API equivalent.
* This function is async because we may need to read a file content.
*
* Convert Boolean to '1' (true) or '0' (false),
* Concatenate two parts adresses (subdomain or email for example) into a single string,
* Convert File to its Base64 representation or set its value to '' to ask for a removal.
*
* @param value - Any {@link PossibleFormValues}
* @return Promise that resolves the formated value
*/
export async function formatFormValue<T extends PossibleFormValues>(
value: T,
): Promise<FormValueReturnType<T>> {
// TODO: couldn't manage proper type checking for this function
// Returned type is ok but it is not type safe since we return `any`
let formated: any = value
if (typeof value === 'boolean') {
formated = value ? 1 : 0
} else if (Array.isArray(value)) {
formated = await Promise.all(value.map((v) => formatFormValue(v)))
} else if (isFileModelValue(value)) {
// File has to be deleted
if (value.removed) formated = ''
// File has not changed (will not be sent)
else if (value.current || value.file === null) formated = null
else {
const filename = value.file.name
formated = await getFileContent(value.file, { base64: true }).then(
(content) => {
return {
content: content.replace(/data:[^;]*;base64,/, ''),
filename,
}
},
)
}
} else if (isAdressModelValue(value)) {
formated = Object.values(value).join('')
}
return formated
}
type FileReturnType<T extends FileModelValue> = T extends {
removed: true
}
? ''
: T extends {
file: File
}
? { content: string; filename: string }
: null
export type FormValueReturnType<T extends PossibleFormValues> =
T extends boolean
? 0 | 1
: T extends FileModelValue
? FileReturnType<T>
: T extends AdressModelValue
? string
: T extends BasePossibleFormValues[]
? FormValueReturnType<ArrInnerType<T>>[]
: T extends string | number | null | undefined
? T
: never
/**
* Format a frontend form to its API equivalent to be sent to the server.
* This function is async because we need to read files content.
*
* /!\ FIXME
* Files type are wrong, they resolves as `{ filename: string; content: string }`
* but in reality they resolves as 2 keys in the returned form. See implementation.
* /!\
*
* @param form - An `Obj` containing form values
* @param removeEmpty - Removes "empty" values (`null | undefined | '' | [] | {}`) from the object
* @param removeNull - Removes `null | undefined` values from the object
* @return API data ready to be sent to the server.
*/
export function formatForm<
T extends Obj<PossibleFormValues>,
R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
>(
form: MaybeRef<T>,
{ removeEmpty }: { removeEmpty: boolean },
): Promise<
Partial<{
// TODO: using `Partial` for now since i'm not sure we can infer empty `'' | [] | {}`
[k in keyof R as R[k] extends undefined | null ? never : k]: R[k]
}>
>
export function formatForm<
T extends Obj<PossibleFormValues>,
R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
>(
form: MaybeRef<T>,
{ removeNullish }: { removeNullish: boolean },
): Promise<{
[k in keyof R as R[k] extends undefined | null ? never : k]: R[k]
}>
export function formatForm<
T extends Obj<PossibleFormValues>,
R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
>(form: MaybeRef<T>): Promise<R>
export function formatForm<
T extends Obj<PossibleFormValues>,
R extends { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
>(
form: MaybeRef<T>,
{ removeEmpty = false, removeNullish = false } = {},
): Promise<FormatFormReturnType<R>> {
const [keys, promises] = toEntries(toValue(form)).reduce(
(acc, [key, v]) => {
acc[0].push(key)
acc[1].push(formatFormValue(v))
return acc
},
[[] as (keyof T)[], [] as Promise<FormValueReturnType<T[keyof T]>>[]],
)
return Promise.all(promises).then((resolvedValues) => {
let entries = resolvedValues.map((v, i) => [keys[i], v] as const)
if (removeEmpty || removeNullish) {
entries = entries.filter((entry) => {
return !(
(removeEmpty && isEmptyValue(entry[1])) ||
(removeNullish && [null, undefined].includes(entry[1] as any))
)
})
}
// Special handling of files which are a bit weird, we inject 2 keys
// in the form, one for the filename and one with its content.
// TODO: could be improved, with a single key for example as to current
// type `{ filename: string; content: string }` and remove the next `reduce`
return entries.reduce(
(form, [k, v]) => {
if (isObjectLiteral(v) && 'filename' in v && 'content' in v) {
// @ts-ignore (mess to type)
form[k] = v.content
// @ts-ignore (mess to type)
form[`${String(k)}[name]`] = v.filename
}
form[k] = v
return form
},
{} as { [k in keyof T]: Awaited<FormValueReturnType<T[k]>> },
)
}) as Promise<FormatFormReturnType<R>>
}
export type FormatFormReturnType<R> =
| Partial<{
[k in keyof R as R[k] extends undefined | null ? never : k]: R[k]
}>
| { [k in keyof R as R[k] extends undefined | null ? never : k]: R[k] }
| R

View file

@ -1,89 +0,0 @@
import store from '@/store'
import i18n from '@/i18n'
import supportedLocales from './supportedLocales'
let dateFnsLocale
const loadedLanguages = []
/**
* Returns the first two supported locales that can be found in the `localStorage` or
* in the user browser settings.
*
* @return {string[]}
*/
function getDefaultLocales() {
const locale = store.getters.locale
const fallbackLocale = store.getters.fallbackLocale
if (locale && fallbackLocale) return [locale, fallbackLocale]
const navigatorLocales = navigator.languages || [navigator.language]
const defaultLocales = []
const supported = Object.keys(supportedLocales)
for (const locale of navigatorLocales) {
if (supported.includes(locale) && !defaultLocales.includes(locale)) {
defaultLocales.push(locale)
} else {
const lang = locale.split('-')[0]
if (supported.includes(lang) && !defaultLocales.includes(lang)) {
defaultLocales.push(lang)
}
}
if (defaultLocales.length === 2) break
}
return defaultLocales
}
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'
}
/**
* Loads a translation file and adds its content to the i18n plugin `messages`.
*
* @return {Promise<string>} Promise that resolve the given locale string
*/
function loadLocaleMessages(locale) {
if (loadedLanguages.includes(locale)) {
return Promise.resolve(locale)
}
return import(`@/i18n/locales/${locale}.json`).then((messages) => {
i18n.setLocaleMessage(locale, messages.default)
loadedLanguages.push(locale)
return locale
})
}
/**
* Loads a date-fns locale object
*/
async function loadDateFnsLocale(locale) {
const dateFnsLocaleName = supportedLocales[locale].dateFnsLocale || locale
dateFnsLocale = (
await import(
`../../node_modules/date-fns/esm/locale/${dateFnsLocaleName}/index.js`
)
).default
}
/**
* Initialize all locales
*/
function initDefaultLocales() {
// Get defined locales from `localStorage` or `navigator`
const [locale, fallbackLocale] = getDefaultLocales()
store.dispatch('UPDATE_LOCALE', locale)
store.dispatch('UPDATE_FALLBACKLOCALE', fallbackLocale || 'en')
return loadLocaleMessages('en')
}
export {
initDefaultLocales,
updateDocumentLocale,
loadLocaleMessages,
loadDateFnsLocale,
dateFnsLocale,
}

82
app/src/i18n/helpers.ts Normal file
View file

@ -0,0 +1,82 @@
import i18n from '@/i18n'
import { nextTick } from 'vue'
import supportedLocales, {
isSupportedLocale,
type SupportedLocales,
} from '@/i18n/supportedLocales'
export let dateFnsLocale: any
/**
* Returns the first two supported locales that can be found in the `localStorage` or
* in the user browser settings.
*/
export function getDefaultLocales() {
const navigatorLocales = navigator.languages || [navigator.language]
const defaultLocales: SupportedLocales[] = []
for (const locale of navigatorLocales) {
if (isSupportedLocale(locale) && !defaultLocales.includes(locale)) {
defaultLocales.push(locale)
} else {
const lang = locale.split('-')[0]
if (isSupportedLocale(lang) && !defaultLocales.includes(lang)) {
defaultLocales.push(lang)
}
}
if (defaultLocales.length === 2) break
}
return defaultLocales as [SupportedLocales, SupportedLocales]
}
export async function setI18nLocale(locale: SupportedLocales) {
if (!i18n.global.availableLocales.includes(locale)) {
await loadLocaleMessages(locale)
// also query/set the date-fns locale object for time translation
await loadDateFnsLocale(locale)
}
// Preload 'en' locales as it is the hard fallback
if (locale !== 'en' && !i18n.global.availableLocales.includes('en')) {
loadLocaleMessages('en')
}
i18n.global.locale.value = locale
document.querySelector('html')!.setAttribute('lang', locale)
// FIXME can't currently change document direction easily since bootstrap still doesn't handle rtl.
// document.dir = locale === 'ar' ? 'rtl' : 'ltr'
}
export async function setI18nFallbackLocale(locale: SupportedLocales) {
if (!i18n.global.availableLocales.includes(locale)) {
await loadLocaleMessages(locale)
}
i18n.global.fallbackLocale.value = [locale, 'en']
}
/**
* Loads a translation file and adds its content to the i18n plugin `messages`.
*
* @return Promise that resolve the given locale string
*/
export async function loadLocaleMessages(locale: SupportedLocales) {
// load locale messages with dynamic import
const messages = await import(`./locales/${locale}.json`)
// set locale and locale message
i18n.global.setLocaleMessage(locale, messages)
return nextTick()
}
/**
* Loads a date-fns locale object
*/
async function loadDateFnsLocale(locale: SupportedLocales) {
const dateFnsLocaleName = supportedLocales[locale].dateFnsLocale ?? locale
dateFnsLocale = (
await import(`../../node_modules/date-fns/locale/${dateFnsLocaleName}.mjs`)
).default
}

View file

@ -1,12 +0,0 @@
/**
* i18n plugin module.
* @module i18n
*/
import Vue from 'vue'
import VueI18n from 'vue-i18n'
// Plugin Initialization
Vue.use(VueI18n)
export default new VueI18n({})

10
app/src/i18n/index.ts Normal file
View file

@ -0,0 +1,10 @@
/**
* i18n plugin module.
* @module i18n
*/
import { createI18n } from 'vue-i18n'
export default createI18n({
legacy: false,
})

View file

@ -329,6 +329,9 @@
"help": "Need help?"
},
"footer_version": "Powered by <a href='https://yunohost.org'>YunoHost</a> {version} ({repo}).",
"form": {
"select_one": "Please select an option"
},
"form_errors": {
"alpha": "Value must be alphabetical characters only.",
"alphalownumdot_": "Value must be lower-case alphanumeric, dots and underscore characters only.",
@ -341,6 +344,7 @@
"invalid_form": "The form contains some errors.",
"maxValue": "Value must be a number equal or lesser than {max}.",
"minValue": "Value must be a number equal or greater than {min}.",
"numValue": "Value must be a number",
"name": "Names may not includes special characters except <code> ,.'-</code>",
"notInUsers": "The user '{value}' already exists.",
"number": "Value must be a number.",
@ -505,6 +509,7 @@
"label_for_manifestname_help": "This is the name displayed in the user portal. This can be changed later.",
"last_ran": "Last time ran:",
"license": "License",
"loading": "Loading",
"local_archives": "Local archives",
"login": "Login",
"logout": "Logout",
@ -590,6 +595,7 @@
"protocol": "Protocol",
"purge_user_data_checkbox": "Purge {name}'s data? (This will remove the content of its home and mail directories.)",
"purge_user_data_warning": "Purging user's data is not reversible. Be sure you know what you're doing!",
"quick_add": "Quick add",
"readme": "Readme",
"rerun_diagnosis": "Rerun diagnosis",
"restart": "Restart",

View file

@ -4,7 +4,7 @@
// If a new locale or a new date-fns locale is added, add it to the supported
// locales list in `app/vue.config.js`
export default {
const supportedLocales = {
ar: {
name: 'عربي',
},
@ -137,4 +137,21 @@ export default {
name: '简化字',
dateFnsLocale: 'zh-CN',
},
} as const
type SL = typeof supportedLocales
export type SupportedLocales = keyof SL
export type SupportedDateFnsLocales = keyof {
[k in SupportedLocales as SL[k] extends { dateFnsLocale: string }
? SL[k]['dateFnsLocale']
: k]: never
}
export function isSupportedLocale(locale: string): locale is SupportedLocales {
return Object.keys(supportedLocales).includes(locale)
}
export default supportedLocales as Record<
SupportedLocales,
{ name: string; dateFnsLocale?: SupportedDateFnsLocales }
>

View file

@ -1,81 +0,0 @@
import Vue from 'vue'
import App from './App.vue'
import BootstrapVue from 'bootstrap-vue'
import VueShowdown from 'vue-showdown'
import store from './store'
import router from './router'
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 },
})
Vue.use(VueShowdown, {
options: {
emoji: true,
},
})
// Ugly wrapper for `$bvModal.msgBoxConfirm` to set default i18n button titles
// FIXME find or wait for a better way
Vue.prototype.$askConfirmation = function (message, props) {
return this.$bvModal.msgBoxConfirm(message, {
okTitle: this.$i18n.t('ok'),
cancelTitle: this.$i18n.t('cancel'),
bodyBgVariant: 'warning',
centered: true,
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 } },
})
return this.$bvModal['msgBox' + (ok ? 'Ok' : 'Confirm')](content, {
okTitle: this.$i18n.t('yes'),
cancelTitle: this.$i18n.t('cancel'),
headerBgVariant: 'warning',
headerClass: store.state.theme ? 'text-white' : 'text-black',
centered: true,
...props,
})
}
// Register global components
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)
})
registerGlobalErrorHandlers()
// Load default locales translations files and setup store data
initDefaultLocales().then(() => {
const app = new Vue({
store,
router,
i18n,
render: (h) => h(App),
})
app.$mount('#app')
})

67
app/src/main.ts Normal file
View file

@ -0,0 +1,67 @@
import { watchOnce } from '@vueuse/core'
import { createBootstrap } from 'bootstrap-vue-next'
import { createApp, type Component } from 'vue'
import { VueShowdownPlugin } from 'vue-showdown'
import App from './App.vue'
import { APIError } from './api/errors'
import { useRequests } from './composables/useRequests'
import { useSettings } from './composables/useSettings'
import i18n from './i18n'
import router from './router'
import '@/scss/main.scss'
type Module = { default: Component }
const app = createApp(App)
// Error catching
function onError(err: unknown) {
if (err instanceof APIError) {
useRequests().handleAPIError(err)
} else {
// FIXME Error modal for internal code error?
throw err
}
}
app.config.errorHandler = (err) => onError(err)
window.addEventListener('unhandledrejection', (e) => {
// Global catching of unhandled promise's rejections.
// Those errors (thrown or rejected from inside a promise) can't be catched by
// `window.onerror` or vue.
e.preventDefault()
onError(e.reason)
})
app.use(router)
app.use(i18n)
app.use(createBootstrap())
app.use(VueShowdownPlugin, {
flavor: 'github',
options: {
emoji: true,
},
})
// Register global components
const globalComponentsModules = import.meta.glob(
['@/components/globals/*.vue', '@/components/globals/*/*.vue'],
{ eager: true },
) as Record<string, Module>
Object.values(globalComponentsModules).forEach(
({ default: component }: Module) => {
// FIXME component name is not automatic (there is the `__name` but it's private and may change)
// Solution seems to use:
// defineOptions({
// name: 'FormField',
// })
// @ts-expect-error
app.component(component.__name || component.name, component)
},
)
// Load default locales translations files then mount the app
watchOnce(useSettings().localesLoaded, () => app.mount('#app'))

View file

@ -1,55 +0,0 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'
import store from '@/store'
Vue.use(VueRouter)
const router = new VueRouter({
// mode: 'history', // this allow all routes to be real ones (without '#')
base: import.meta.env.BASE_URL,
routes,
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.
// if animations are enabled, we need to delay a bit the returned value of the saved
// 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) => {
setTimeout(() => resolve(savedPosition), 0)
})
} else {
return savedPosition || { x: 0, y: 0 }
}
},
})
router.beforeEach((to, from, next) => {
if (store.getters.transitions && from.name !== null) {
store.dispatch('UPDATE_TRANSITION_NAME', { to, from })
}
if (store.getters.error) {
store.dispatch('DISMISS_ERROR', true)
}
if (to.name === 'post-install' && store.getters.installed) {
return next('/')
}
// Allow if connected or route is not protected
if (store.getters.connected || to.meta.noAuth) {
next()
} else {
store.dispatch('DISCONNECT', to)
}
})
router.afterEach((to, from) => {
store.dispatch('UPDATE_ROUTER_KEY', { to, from })
store.dispatch('UPDATE_BREADCRUMB', { to, from })
})
export default router

54
app/src/router/index.ts Normal file
View file

@ -0,0 +1,54 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useInfos } from '@/composables/useInfos'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import routes from './routes'
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes,
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.
// if animations are enabled, we need to delay a bit the returned value of the saved
// 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
const { transitions } = useSettings()
if (transitions.value && savedPosition) {
return new Promise((resolve) => {
setTimeout(() => resolve(savedPosition), 0)
})
} else {
return savedPosition || { left: 0, top: 0 }
}
},
})
router.beforeEach((to, from, next) => {
const { transitions, updateTransitionName } = useSettings()
if (transitions.value && from.name !== null) {
updateTransitionName({ to, from })
}
const { currentRequest, dismissModal } = useRequests()
if (currentRequest.value?.err) {
// In case an error is still present after code route change
dismissModal(currentRequest.value.id)
}
const { installed, connected, onLogout } = useInfos()
if (to.name === 'post-install' && installed.value) {
return next('/')
}
// Allow if connected or route is not protected
if (connected.value || to.meta.noAuth) {
next()
} else {
onLogout(to)
}
})
export default router

View file

@ -1,18 +1,14 @@
/**
* routes module.
* @module router/routes
*/
// Simple views are normally imported and will be included into the main webpack entry.
// Others will be chunked by webpack so they can be lazy loaded.
// Webpack chunk syntax is:
// Others will be chunked so they can be lazy loaded:
// `() => import('@/views/:ViewComponent.vue')`
import type { RouteRecordRaw } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import LoginView from '@/views/LoginView.vue'
import ToolList from '@/views/tool/ToolList.vue'
const routes = [
const routes: RouteRecordRaw[] = [
{
name: 'home',
path: '/',
@ -55,6 +51,7 @@ const routes = [
meta: {
args: { trad: 'users' },
breadcrumb: ['user-list'],
skeleton: 'ListGroupSkeleton',
},
},
{
@ -64,6 +61,7 @@ const routes = [
meta: {
args: { trad: 'users_new' },
breadcrumb: ['user-list', 'user-create'],
skeleton: 'CardFormSkeleton',
},
},
{
@ -84,6 +82,7 @@ const routes = [
meta: {
args: { param: 'name' },
breadcrumb: ['user-list', 'user-info'],
skeleton: 'CardInfoSkeleton',
},
},
{
@ -94,6 +93,7 @@ const routes = [
meta: {
args: { param: 'name', trad: 'user_username_edit' },
breadcrumb: ['user-list', 'user-info', 'user-edit'],
skeleton: 'CardFormSkeleton',
},
},
@ -107,6 +107,7 @@ const routes = [
meta: {
args: { trad: 'groups_and_permissions' },
breadcrumb: ['user-list', 'group-list'],
skeleton: 'CardFormSkeleton',
},
},
{
@ -129,6 +130,7 @@ const routes = [
meta: {
args: { trad: 'domains' },
breadcrumb: ['domain-list'],
skeleton: 'ListGroupSkeleton',
},
},
{
@ -138,25 +140,20 @@ const routes = [
meta: {
args: { trad: 'domain_add' },
breadcrumb: ['domain-list', 'domain-add'],
skeleton: 'CardFormSkeleton',
},
},
{
path: '/domains/:name',
name: 'domain-info',
path: '/domains/:name/:tabId?',
component: () => import('@/views/domain/DomainInfo.vue'),
props: true,
children: [
{
name: 'domain-info',
path: ':tabId?',
component: () => import('@/components/ConfigPanel.vue'),
props: true,
meta: {
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
args: { param: 'name' },
breadcrumb: ['domain-list', 'domain-info'],
},
},
],
meta: {
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
args: { param: 'name' },
breadcrumb: ['domain-list', 'domain-info'],
skeleton: 'CardListSkeleton',
},
},
/*
@ -169,6 +166,7 @@ const routes = [
meta: {
args: { trad: 'applications' },
breadcrumb: ['app-list'],
skeleton: 'ListGroupSkeleton',
},
},
{
@ -179,6 +177,7 @@ const routes = [
meta: {
args: { trad: 'catalog' },
breadcrumb: ['app-list', 'app-catalog'],
skeleton: 'AppCatalogSkeleton',
},
},
{
@ -189,6 +188,7 @@ const routes = [
meta: {
args: { trad: 'install_name', param: 'id' },
breadcrumb: ['app-list', 'app-catalog', 'app-install'],
skeleton: ['CardInfoSkeleton', { is: 'CardFormSkeleton', cols: null }],
},
},
{
@ -199,25 +199,20 @@ const routes = [
meta: {
args: { trad: 'install_name', param: 'id' },
breadcrumb: ['app-list', 'app-catalog', 'app-install-custom'],
skeleton: ['CardInfoSkeleton', { is: 'CardFormSkeleton', cols: null }],
},
},
{
path: '/apps/:id',
name: 'app-info',
path: '/apps/:id/:tabId?',
component: () => import('@/views/app/AppInfo.vue'),
props: true,
children: [
{
name: 'app-info',
path: ':tabId?',
component: () => import('@/components/ConfigPanel.vue'),
props: true,
meta: {
routerParams: ['id'], // Override router key params to avoid view recreation at tab change.
args: { param: 'id' },
breadcrumb: ['app-list', 'app-info'],
},
},
],
meta: {
routerParams: ['id'], // Override router key params to avoid view recreation at tab change.
args: { param: 'id' },
breadcrumb: ['app-list', 'app-info'],
skeleton: [{ is: 'CardInfoSkeleton', itemCount: 8 }, 'CardFormSkeleton'],
},
},
/*
@ -230,6 +225,7 @@ const routes = [
meta: {
args: { trad: 'system_update' },
breadcrumb: ['update'],
skeleton: 'CardListSkeleton',
},
},
@ -243,6 +239,7 @@ const routes = [
meta: {
args: { trad: 'services' },
breadcrumb: ['tool-list', 'service-list'],
skeleton: { is: 'ListGroupSkeleton', button: false },
},
},
{
@ -253,6 +250,7 @@ const routes = [
meta: {
args: { param: 'name' },
breadcrumb: ['tool-list', 'service-list', 'service-info'],
skeleton: ['CardInfoSkeleton', 'CardInfoSkeleton'],
},
},
@ -275,16 +273,18 @@ const routes = [
meta: {
args: { trad: 'logs' },
breadcrumb: ['tool-list', 'tool-logs'],
skeleton: { is: 'CardListSkeleton', search: true },
},
},
{
name: 'tool-log',
path: '/tools/logs/:name',
path: '/tools/logs/:name/:n?',
component: () => import('@/views/tool/ToolLog.vue'),
props: true,
meta: {
args: { param: 'name' },
breadcrumb: ['tool-list', 'tool-logs', 'tool-log'],
skeleton: ['CardInfoSkeleton', 'CardInfoSkeleton'],
},
},
{
@ -294,6 +294,10 @@ const routes = [
meta: {
args: { trad: 'migrations' },
breadcrumb: ['tool-list', 'tool-migrations'],
skeleton: [
{ is: 'CardListSkeleton', itemCount: 3 },
{ is: 'CardListSkeleton', itemCount: 3 },
],
},
},
{
@ -303,6 +307,7 @@ const routes = [
meta: {
args: { trad: 'firewall' },
breadcrumb: ['tool-list', 'tool-firewall'],
skeleton: 'CardFormSkeleton',
},
},
{
@ -315,21 +320,16 @@ const routes = [
},
},
{
path: '/tools/settings',
name: 'tool-settings',
path: '/tools/settings/:tabId?',
component: () => import('@/views/tool/ToolSettings.vue'),
children: [
{
name: 'tool-settings',
path: ':tabId?',
component: () => import('@/components/ConfigPanel.vue'),
props: true,
meta: {
routerParams: [],
args: { trad: 'tools_yunohost_settings' },
breadcrumb: ['tool-list', 'tool-settings'],
},
},
],
props: true,
meta: {
routerParams: [],
args: { trad: 'tools_yunohost_settings' },
breadcrumb: ['tool-list', 'tool-settings'],
skeleton: 'CardFormSkeleton',
},
},
{
name: 'tool-power',
@ -351,6 +351,7 @@ const routes = [
meta: {
args: { trad: 'diagnosis' },
breadcrumb: ['diagnosis'],
skeleton: ['CardListSkeleton', 'CardListSkeleton', 'CardListSkeleton'],
},
},
@ -374,6 +375,7 @@ const routes = [
meta: {
args: { param: 'id' },
breadcrumb: ['backup', 'backup-list'],
skeleton: 'ListGroupSkeleton',
},
},
{
@ -384,6 +386,7 @@ const routes = [
meta: {
args: { param: 'name' },
breadcrumb: ['backup', 'backup-list', 'backup-info'],
skeleton: [{ is: 'CardInfoSkeleton', itemCount: 4 }, 'CardListSkeleton'],
},
},
{
@ -394,6 +397,7 @@ const routes = [
meta: {
args: { trad: 'backup_create' },
breadcrumb: ['backup', 'backup-list', 'backup-create'],
skeleton: 'CardListSkeleton',
},
},
]

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