mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
Merge be8b077586
into 2cce638d90
This commit is contained in:
commit
0cbed9fd14
164 changed files with 12643 additions and 11165 deletions
|
@ -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
125
app/components.d.ts
vendored
Normal 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
1
app/env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -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
22
app/overrides.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
|
@ -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%",
|
||||
|
|
232
app/src/App.vue
232
app/src/App.vue
|
@ -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 {
|
||||
|
|
|
@ -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
284
app/src/api/api.ts
Normal 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)
|
||||
})
|
||||
},
|
||||
}
|
|
@ -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
165
app/src/api/errors.ts
Normal 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,
|
||||
}
|
|
@ -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
99
app/src/api/handlers.ts
Normal 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)
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
export { default, objectToParams } from './api'
|
||||
export { handleError, registerGlobalErrorHandlers } from './handlers'
|
2
app/src/api/index.ts
Normal file
2
app/src/api/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default, objectToParams } from './api'
|
||||
export { getError } from './handlers'
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
248
app/src/components/globals/FormFieldMultiple.vue
Normal file
248
app/src/components/globals/FormFieldMultiple.vue
Normal 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>
|
79
app/src/components/globals/FormFieldReadonly.vue
Normal file
79
app/src/components/globals/FormFieldReadonly.vue
Normal 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>
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 > * {
|
||||
|
|
41
app/src/components/globals/YListItem.vue
Normal file
41
app/src/components/globals/YListItem.vue
Normal 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>
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
71
app/src/components/globals/formItems/AdressItem.vue
Normal file
71
app/src/components/globals/formItems/AdressItem.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
56
app/src/components/globals/skeletons/AppCatalogSkeleton.vue
Normal file
56
app/src/components/globals/skeletons/AppCatalogSkeleton.vue
Normal 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>
|
26
app/src/components/globals/skeletons/BSkeleton.vue
Normal file
26
app/src/components/globals/skeletons/BSkeleton.vue
Normal 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>
|
95
app/src/components/globals/skeletons/BSkeletonWrapper.vue
Normal file
95
app/src/components/globals/skeletons/BSkeletonWrapper.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
165
app/src/components/layouts/MainLayout.vue
Normal file
165
app/src/components/layouts/MainLayout.vue
Normal 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>
|
58
app/src/components/modals/ModalError.vue
Normal file
58
app/src/components/modals/ModalError.vue
Normal 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>
|
67
app/src/components/modals/ModalOverlay.vue
Normal file
67
app/src/components/modals/ModalOverlay.vue
Normal 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>
|
83
app/src/components/modals/ModalReconnecting.vue
Normal file
83
app/src/components/modals/ModalReconnecting.vue
Normal 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>
|
49
app/src/components/modals/ModalWaiting.vue
Normal file
49
app/src/components/modals/ModalWaiting.vue
Normal 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>
|
27
app/src/components/modals/ModalWarning.vue
Normal file
27
app/src/components/modals/ModalWarning.vue
Normal 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>
|
13
app/src/components/modals/index.ts
Normal file
13
app/src/components/modals/index.ts
Normal 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,
|
||||
}
|
479
app/src/composables/configPanels.ts
Normal file
479
app/src/composables/configPanels.ts
Normal 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
266
app/src/composables/data.ts
Normal 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
185
app/src/composables/form.ts
Normal 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
|
||||
}
|
45
app/src/composables/useAutoModal.ts
Normal file
45
app/src/composables/useAutoModal.ts
Normal 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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
219
app/src/composables/useInfos.ts
Normal file
219
app/src/composables/useInfos.ts
Normal 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,
|
||||
}
|
||||
})
|
209
app/src/composables/useRequests.ts
Normal file
209
app/src/composables/useRequests.ts
Normal 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,
|
||||
}
|
||||
})
|
50
app/src/composables/useSearch.ts
Normal file
50
app/src/composables/useSearch.ts
Normal 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]
|
||||
}
|
75
app/src/composables/useSettings.ts
Normal file
75
app/src/composables/useSettings.ts
Normal 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,
|
||||
}
|
||||
})
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
182
app/src/helpers/commons.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]>
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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 })
|
||||
}
|
21
app/src/helpers/filters/date.ts
Normal file
21
app/src/helpers/filters/date.ts
Normal 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 })
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
16
app/src/helpers/filters/human.ts
Normal file
16
app/src/helpers/filters/human.ts
Normal 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()
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -9,4 +9,4 @@ export {
|
|||
minValue,
|
||||
required,
|
||||
sameAs,
|
||||
} from 'vuelidate/lib/validators'
|
||||
} from '@vuelidate/validators'
|
|
@ -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
|
||||
}
|
256
app/src/helpers/yunohostArguments.ts
Normal file
256
app/src/helpers/yunohostArguments.ts
Normal 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
|
|
@ -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
82
app/src/i18n/helpers.ts
Normal 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
|
||||
}
|
|
@ -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
10
app/src/i18n/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* i18n plugin module.
|
||||
* @module i18n
|
||||
*/
|
||||
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
export default createI18n({
|
||||
legacy: false,
|
||||
})
|
|
@ -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",
|
||||
|
|
|
@ -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 }
|
||||
>
|
|
@ -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
67
app/src/main.ts
Normal 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'))
|
|
@ -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
54
app/src/router/index.ts
Normal 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
|
|
@ -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
Loading…
Add table
Reference in a new issue