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,
|
node: true,
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
'plugin:vue/strongly-recommended',
|
'plugin:vue/vue3-recommended',
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
|
'@vue/eslint-config-typescript',
|
||||||
'plugin:prettier/recommended',
|
'plugin:prettier/recommended',
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'no-unused-vars': [
|
'vue/no-v-html': 'off',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'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>
|
</strong>
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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:js": "eslint --ext \".ts,.vue,.cjs,.js\" --ignore-path ../.gitignore .",
|
||||||
"lint:prettier": "prettier --check .",
|
"lint:prettier": "prettier --check .",
|
||||||
"lint": "yarn lint:js && yarn lint:prettier",
|
"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": {
|
"dependencies": {
|
||||||
"@fontsource/fira-code": "^4.5.13",
|
"@fontsource/fira-code": "^5.0.18",
|
||||||
"@fontsource/firago": "^4.5.3",
|
"@fontsource/firago": "^5.0.11",
|
||||||
"bootstrap-vue": "^2.22.0",
|
"@vuelidate/core": "^2.0.3",
|
||||||
"date-fns": "^2.29.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",
|
"fork-awesome": "^1.2.0",
|
||||||
"simple-evaluate": "^1.4.6",
|
"simple-evaluate": "^1.4.6",
|
||||||
"vue": "^2.7.14",
|
"uuid": "^10.0.0",
|
||||||
"vue-i18n": "^8.28.2",
|
"vue": "^3.4.37",
|
||||||
"vue-router": "^3.6.5",
|
"vue-i18n": "^9.13.1",
|
||||||
"vue-showdown": "^2.4.1",
|
"vue-router": "^4.4.3",
|
||||||
"vuelidate": "^0.7.7",
|
"vue-showdown": "^4.2.0"
|
||||||
"vuex": "^3.6.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue2": "^2.2.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"bootstrap": "^4.6.0",
|
"@vitejs/plugin-vue": "^5.1.2",
|
||||||
"eslint": "^8.36.0",
|
"@vue/eslint-config-typescript": "^13.0.0",
|
||||||
|
"@vue/tsconfig": "^0.5.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-vue": "^9.10.0",
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"popper.js": "^1.16.0",
|
"prettier": "^3.3.3",
|
||||||
"portal-vue": "^2.1.7",
|
"sass": "^1.77.8",
|
||||||
"prettier": "^3.2.5",
|
"typescript": "^5.5.4",
|
||||||
"sass": "^1.60.0",
|
"unplugin-vue-components": "^0.27.4",
|
||||||
"vite": "^4.5.3"
|
"vite": "^5.4.0",
|
||||||
|
"vue-tsc": "^2.0.29"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 1%",
|
"> 1%",
|
||||||
|
|
278
app/src/App.vue
278
app/src/App.vue
|
@ -1,138 +1,19 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<div id="app" class="container">
|
import { onMounted, ref } from 'vue'
|
||||||
<!-- HEADER -->
|
|
||||||
<header>
|
|
||||||
<BNavbar>
|
|
||||||
<BNavbarBrand
|
|
||||||
:to="{ name: 'home' }"
|
|
||||||
:disabled="waiting"
|
|
||||||
exact
|
|
||||||
exact-active-class="active"
|
|
||||||
>
|
|
||||||
<span v-if="theme">
|
|
||||||
<img alt="YunoHost logo" src="./assets/logo_light.png" width="40" />
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
<img alt="YunoHost logo" src="./assets/logo_dark.png" width="40" />
|
|
||||||
</span>
|
|
||||||
</BNavbarBrand>
|
|
||||||
|
|
||||||
<BNavbarNav class="ml-auto">
|
import { useInfos } from '@/composables/useInfos'
|
||||||
<li class="nav-item">
|
import { useRequests } from '@/composables/useRequests'
|
||||||
<BButton :href="ssoLink" variant="primary" size="sm" block>
|
import { useSettings } from '@/composables/useSettings'
|
||||||
{{ $t('user_interface_link') }} <YIcon iname="user" />
|
import { HistoryConsole } from '@/views/_partials'
|
||||||
</BButton>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="nav-item" v-show="connected">
|
const { ssoLink, connected, yunohost, logout, onAppCreated } = useInfos()
|
||||||
<BButton
|
const { locked } = useRequests()
|
||||||
@click.prevent="logout"
|
const { spinner, dark } = useSettings()
|
||||||
variant="outline-dark"
|
|
||||||
block
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{{ $t('logout') }} <YIcon iname="sign-out" />
|
|
||||||
</BButton>
|
|
||||||
</li>
|
|
||||||
</BNavbarNav>
|
|
||||||
</BNavbar>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- MAIN -->
|
const ready = ref(false)
|
||||||
<ViewLockOverlay>
|
onAppCreated().then(() => (ready.value = true))
|
||||||
<YBreadcrumb />
|
|
||||||
|
|
||||||
<main id="main">
|
onMounted(() => {
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- HISTORY CONSOLE -->
|
|
||||||
<HistoryConsole />
|
|
||||||
|
|
||||||
<!-- FOOTER -->
|
|
||||||
<footer class="py-3 mt-auto">
|
|
||||||
<nav>
|
|
||||||
<BNav class="justify-content-center">
|
|
||||||
<BNavItem
|
|
||||||
href="https://yunohost.org/docs"
|
|
||||||
target="_blank"
|
|
||||||
link-classes="text-secondary"
|
|
||||||
>
|
|
||||||
<YIcon iname="book" /> {{ $t('footer.documentation') }}
|
|
||||||
</BNavItem>
|
|
||||||
<BNavItem
|
|
||||||
href="https://yunohost.org/help"
|
|
||||||
target="_blank"
|
|
||||||
link-classes="text-secondary"
|
|
||||||
>
|
|
||||||
<YIcon iname="life-ring" /> {{ $t('footer.help') }}
|
|
||||||
</BNavItem>
|
|
||||||
<BNavItem
|
|
||||||
href="https://donate.yunohost.org/"
|
|
||||||
target="_blank"
|
|
||||||
link-classes="text-secondary"
|
|
||||||
>
|
|
||||||
<YIcon iname="heart" /> {{ $t('footer.donate') }}
|
|
||||||
</BNavItem>
|
|
||||||
|
|
||||||
<BNavText
|
|
||||||
v-if="yunohost"
|
|
||||||
id="yunohost-version"
|
|
||||||
class="ml-md-auto text-center"
|
|
||||||
>
|
|
||||||
<span v-html="$t('footer_version', yunohost)" />
|
|
||||||
</BNavText>
|
|
||||||
</BNav>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
|
||||||
</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']
|
const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp']
|
||||||
let copypastastep = 0
|
let copypastastep = 0
|
||||||
document.addEventListener('keydown', ({ key }) => {
|
document.addEventListener('keydown', ({ key }) => {
|
||||||
|
@ -165,7 +46,7 @@ export default {
|
||||||
document.addEventListener('keydown', ({ key }) => {
|
document.addEventListener('keydown', ({ key }) => {
|
||||||
if (key === konamiCode[konamistep++]) {
|
if (key === konamiCode[konamistep++]) {
|
||||||
if (konamistep === konamiCode.length) {
|
if (konamistep === konamiCode.length) {
|
||||||
this.$store.commit('SET_SPINNER', 'nyancat')
|
spinner.value = 'nyancat'
|
||||||
konamistep = 0
|
konamistep = 0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -176,23 +57,107 @@ export default {
|
||||||
// April fools easter egg ;)
|
// April fools easter egg ;)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
if (today.getDate() === 1 && today.getMonth() + 1 === 4) {
|
if (today.getDate() === 1 && today.getMonth() + 1 === 4) {
|
||||||
this.$store.commit('SET_SPINNER', 'magikarp')
|
spinner.value = 'magikarp'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Halloween easter egg ;)
|
// Halloween easter egg ;)
|
||||||
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
|
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
|
||||||
this.$store.commit('SET_SPINNER', 'spookycat')
|
spinner.value = 'spookycat'
|
||||||
}
|
}
|
||||||
|
})
|
||||||
document.documentElement.setAttribute('dark-theme', this.theme) // updates the data-theme attribute
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<template>
|
||||||
// Global import of Bootstrap and custom styles
|
<div id="app" class="container">
|
||||||
@import '@/scss/main.scss';
|
<!-- HEADER -->
|
||||||
</style>
|
<header>
|
||||||
|
<BNavbar>
|
||||||
|
<BNavbarBrand
|
||||||
|
:to="{ name: 'home' }"
|
||||||
|
:disabled="locked"
|
||||||
|
exact-active-class="active"
|
||||||
|
>
|
||||||
|
<span v-if="dark">
|
||||||
|
<img alt="YunoHost logo" src="./assets/logo_light.png" width="40" />
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<img alt="YunoHost logo" src="./assets/logo_dark.png" width="40" />
|
||||||
|
</span>
|
||||||
|
</BNavbarBrand>
|
||||||
|
|
||||||
|
<BNavbarNav class="ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<BButton
|
||||||
|
:href="ssoLink"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
class="d-block"
|
||||||
|
>
|
||||||
|
{{ $t('user_interface_link') }} <YIcon iname="user" />
|
||||||
|
</BButton>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li v-show="connected" class="nav-item">
|
||||||
|
<BButton
|
||||||
|
variant="outline-dark"
|
||||||
|
block
|
||||||
|
size="sm"
|
||||||
|
@click.prevent="logout"
|
||||||
|
>
|
||||||
|
{{ $t('logout') }} <YIcon iname="sign-out" />
|
||||||
|
</BButton>
|
||||||
|
</li>
|
||||||
|
</BNavbarNav>
|
||||||
|
</BNavbar>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<MainLayout v-if="ready" />
|
||||||
|
|
||||||
|
<BModalOrchestrator />
|
||||||
|
|
||||||
|
<!-- HISTORY CONSOLE -->
|
||||||
|
<HistoryConsole />
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<div class="mt-4" />
|
||||||
|
<footer class="py-3 mt-auto">
|
||||||
|
<nav>
|
||||||
|
<BNav class="justify-content-center">
|
||||||
|
<BNavItem
|
||||||
|
href="https://yunohost.org/docs"
|
||||||
|
target="_blank"
|
||||||
|
link-classes="text-secondary"
|
||||||
|
>
|
||||||
|
<YIcon iname="book" /> {{ $t('footer.documentation') }}
|
||||||
|
</BNavItem>
|
||||||
|
<BNavItem
|
||||||
|
href="https://yunohost.org/help"
|
||||||
|
target="_blank"
|
||||||
|
link-classes="text-secondary"
|
||||||
|
>
|
||||||
|
<YIcon iname="life-ring" /> {{ $t('footer.help') }}
|
||||||
|
</BNavItem>
|
||||||
|
<BNavItem
|
||||||
|
href="https://donate.yunohost.org/"
|
||||||
|
target="_blank"
|
||||||
|
link-classes="text-secondary"
|
||||||
|
>
|
||||||
|
<YIcon iname="heart" /> {{ $t('footer.donate') }}
|
||||||
|
</BNavItem>
|
||||||
|
|
||||||
|
<BNavText
|
||||||
|
v-if="yunohost"
|
||||||
|
id="yunohost-version"
|
||||||
|
class="ms-md-auto text-center"
|
||||||
|
>
|
||||||
|
<span v-html="$t('footer_version', yunohost)" />
|
||||||
|
</BNavText>
|
||||||
|
</BNav>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
// generic style for <html>, <body> and <#app> is in `scss/main.scss`
|
// generic style for <html>, <body> and <#app> is in `scss/main.scss`
|
||||||
|
@ -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 {
|
#console {
|
||||||
// Allows the console to be tabbed before the footer links while remaining visually
|
// Allows the console to be tabbed before the footer links while remaining visually
|
||||||
// the last element of the page
|
// the last element of the page
|
||||||
|
@ -255,7 +192,6 @@ main {
|
||||||
footer {
|
footer {
|
||||||
border-top: $thin-border;
|
border-top: $thin-border;
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
margin-top: 2rem;
|
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
& + .nav-item a::before {
|
& + .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>
|
<template>
|
||||||
<BCard v-bind="$attrs" no-body :class="_class">
|
<BCard no-body :class="class_">
|
||||||
<template #header>
|
<template #header>
|
||||||
<slot name="header">
|
<slot name="header">
|
||||||
<h2>
|
<h2>
|
||||||
|
@ -9,7 +40,7 @@
|
||||||
class="card-collapse-button"
|
class="card-collapse-button"
|
||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
<YIcon class="ml-auto" iname="chevron-right" />
|
<YIcon class="ms-auto" iname="chevron-right" />
|
||||||
</BButton>
|
</BButton>
|
||||||
</h2>
|
</h2>
|
||||||
</slot>
|
</slot>
|
||||||
|
@ -21,36 +52,9 @@
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.card-collapse {
|
.card-collapse {
|
||||||
.card-header {
|
:deep(.card-header) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,7 +82,7 @@ export default {
|
||||||
@each $color, $value in $theme-colors {
|
@each $color, $value in $theme-colors {
|
||||||
&-#{$color} {
|
&-#{$color} {
|
||||||
background-color: $value;
|
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
|
// Implementation of the feed pattern
|
||||||
// https://www.w3.org/WAI/ARIA/apg/patterns/feed/
|
// https://www.w3.org/WAI/ARIA/apg/patterns/feed/
|
||||||
|
|
||||||
export default {
|
const props = withDefaults(defineProps<{ stacks?: number }>(), { stacks: 21 })
|
||||||
name: 'CardDeckFeed',
|
const slots = defineSlots<{
|
||||||
|
default: any
|
||||||
|
}>()
|
||||||
|
|
||||||
props: {
|
const busy = ref(false)
|
||||||
stacks: { type: Number, default: 21 },
|
const range = ref(props.stacks)
|
||||||
},
|
const childrenCount = ref(slots.default()[0].children.length)
|
||||||
|
const feedElem = ref<InstanceType<typeof BCardGroup> | null>(null)
|
||||||
|
|
||||||
data() {
|
function getTopParent(prev: HTMLElement): HTMLElement {
|
||||||
return {
|
return prev.parentElement === feedElem.value?.$el
|
||||||
busy: false,
|
|
||||||
range: this.stacks,
|
|
||||||
childrenCount: this.$slots.default.length,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
getTopParent(prev) {
|
|
||||||
return prev.parentElement === this.$refs.feed
|
|
||||||
? prev
|
? prev
|
||||||
: this.getTopParent(prev.parentElement)
|
: getTopParent(prev.parentElement!)
|
||||||
},
|
}
|
||||||
|
|
||||||
onScroll() {
|
function onScroll() {
|
||||||
const elem = this.$refs.feed
|
const elem = feedElem.value?.$el
|
||||||
if (
|
if (
|
||||||
window.innerHeight >
|
window.innerHeight >
|
||||||
elem.clientHeight + elem.getBoundingClientRect().top - 200
|
elem.clientHeight + elem.getBoundingClientRect().top - 200
|
||||||
) {
|
) {
|
||||||
this.busy = true
|
busy.value = true
|
||||||
this.range = Math.min(this.range + this.stacks, this.childrenCount)
|
range.value = Math.min(range.value + props.stacks, childrenCount.value)
|
||||||
this.$nextTick().then(() => {
|
nextTick().then(() => {
|
||||||
this.busy = false
|
busy.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
onKeydown(e) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (['PageUp', 'PageDown'].includes(e.code)) {
|
if (['PageUp', 'PageDown'].includes(e.code)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const key = e.code === 'PageUp' ? 'previous' : 'next'
|
const key = e.code === 'PageUp' ? 'previous' : 'next'
|
||||||
const sibling = this.getTopParent(e.target)[`${key}ElementSibling`]
|
const sibling = getTopParent(e.target as HTMLElement)[
|
||||||
if (sibling) {
|
`${key}ElementSibling`
|
||||||
sibling.focus()
|
] as HTMLElement | null
|
||||||
sibling.scrollIntoView({ block: 'center' })
|
sibling?.focus()
|
||||||
}
|
sibling?.scrollIntoView({ block: 'center' })
|
||||||
}
|
}
|
||||||
// FIXME Add `Home` and `End` shorcuts
|
// 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)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</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>
|
<template>
|
||||||
<div class="config-panel">
|
<BCard v-if="routes.length > 1" no-body class="config-panel">
|
||||||
<RoutableTabs
|
<BCardHeader tag="nav">
|
||||||
v-if="routes_.length > 1"
|
<BNav card-header fill pills>
|
||||||
:routes="routes_"
|
<BNavItem
|
||||||
v-bind="{ panels, forms, v: $v, ...$attrs }"
|
v-for="route in routes"
|
||||||
v-on="$listeners"
|
:key="route.text"
|
||||||
|
:to="route.to"
|
||||||
|
:active="currentRoute.params.tabId === route.to.params?.tabId"
|
||||||
>
|
>
|
||||||
<template #tab-top>
|
<!-- 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 #top>
|
||||||
<slot name="tab-top" />
|
<slot name="tab-top" />
|
||||||
</template>
|
</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" />
|
<slot name="tab-before" />
|
||||||
</template>
|
</template>
|
||||||
<template #tab-after>
|
<template v-if="slots.default" #default>
|
||||||
|
<slot name="default" />
|
||||||
|
</template>
|
||||||
|
<template #after-form>
|
||||||
<slot name="tab-after" />
|
<slot name="tab-after" />
|
||||||
</template>
|
</template>
|
||||||
</RoutableTabs>
|
</CardForm>
|
||||||
|
</BCard>
|
||||||
<YCard v-else :title="routes_[0].text" :icon="routes_[0].icon">
|
<YCard v-else :title="routes[0].text" :icon="routes[0].icon">
|
||||||
<slot name="tab-top" />
|
<slot name="tab-top" />
|
||||||
<slot name="tab-before" />
|
<slot name="tab-before" />
|
||||||
|
<slot name="default" />
|
||||||
<slot name="tab-after" />
|
<slot name="tab-after" />
|
||||||
</YCard>
|
</YCard>
|
||||||
</div>
|
|
||||||
</template>
|
</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,34 +1,37 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<div class="lazy-renderer" :style="`min-height: ${fixedMinHeight}px`">
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
<slot v-if="render" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
const props = withDefaults(
|
||||||
export default {
|
defineProps<{
|
||||||
name: 'LazyRenderer',
|
unrender?: boolean
|
||||||
|
minHeight?: number
|
||||||
props: {
|
renderDelay?: number
|
||||||
unrender: { type: Boolean, default: true },
|
unrenderDelay?: number
|
||||||
minHeight: { type: Number, default: 0 },
|
rootMargin?: string
|
||||||
renderDelay: { type: Number, default: 100 },
|
}>(),
|
||||||
unrenderDelay: { type: Number, default: 2000 },
|
{
|
||||||
rootMargin: { type: String, default: '300px' },
|
unrender: true,
|
||||||
|
minHeight: 0,
|
||||||
|
renderDelay: 100,
|
||||||
|
unrenderDelay: 2000,
|
||||||
|
rootMargin: '300px',
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
|
||||||
data() {
|
defineSlots<{
|
||||||
return {
|
default: any
|
||||||
observer: null,
|
}>()
|
||||||
render: false,
|
|
||||||
fixedMinHeight: this.minHeight,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
const observer = ref<IntersectionObserver | null>(null)
|
||||||
let unrenderTimer
|
const render = ref(false)
|
||||||
let renderTimer
|
const fixedMinHeight = ref(props.minHeight)
|
||||||
|
const rootElem = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
this.observer = new IntersectionObserver(
|
onMounted(() => {
|
||||||
|
let unrenderTimer: number
|
||||||
|
let renderTimer: number
|
||||||
|
|
||||||
|
observer.value = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
let intersecting = entries[0].isIntersecting
|
let intersecting = entries[0].isIntersecting
|
||||||
|
|
||||||
|
@ -36,41 +39,50 @@ export default {
|
||||||
// Intersection is triggered but even if the element is indeed in the viewport,
|
// Intersection is triggered but even if the element is indeed in the viewport,
|
||||||
// isIntersecting is `false`, so we have to manually check this…
|
// isIntersecting is `false`, so we have to manually check this…
|
||||||
// FIXME Would be great to find out why this is happening
|
// FIXME Would be great to find out why this is happening
|
||||||
if (!intersecting && this.$el.offsetTop < window.innerHeight) {
|
if (!intersecting && rootElem.value!.offsetTop < window.innerHeight) {
|
||||||
intersecting = true
|
intersecting = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intersecting) {
|
if (intersecting) {
|
||||||
clearTimeout(unrenderTimer)
|
clearTimeout(unrenderTimer)
|
||||||
// Show the component after a delay (to avoid rendering while scrolling fast)
|
// Show the component after a delay (to avoid rendering while scrolling fast)
|
||||||
renderTimer = setTimeout(
|
renderTimer = window.setTimeout(
|
||||||
() => {
|
() => {
|
||||||
this.render = true
|
render.value = true
|
||||||
},
|
},
|
||||||
this.unrender ? this.renderDelay : 0,
|
props.unrender ? props.renderDelay : 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!this.unrender) {
|
if (!props.unrender) {
|
||||||
// Stop listening to intersections after first appearance if unrendering is not activated
|
// Stop listening to intersections after first appearance if unrendering is not activated
|
||||||
this.observer.disconnect()
|
observer.value!.disconnect()
|
||||||
}
|
}
|
||||||
} else if (this.unrender) {
|
} else if (props.unrender) {
|
||||||
clearTimeout(renderTimer)
|
clearTimeout(renderTimer)
|
||||||
// Hide the component after a delay if it's no longer in the viewport
|
// Hide the component after a delay if it's no longer in the viewport
|
||||||
unrenderTimer = setTimeout(() => {
|
unrenderTimer = window.setTimeout(() => {
|
||||||
this.fixedMinHeight = this.$el.clientHeight
|
fixedMinHeight.value = rootElem.value!.clientHeight
|
||||||
this.render = false
|
render.value = false
|
||||||
}, this.unrenderDelay)
|
}, props.unrenderDelay)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: this.rootMargin },
|
{ rootMargin: props.rootMargin },
|
||||||
)
|
)
|
||||||
|
|
||||||
this.observer.observe(this.$el)
|
observer.value.observe(rootElem.value!)
|
||||||
},
|
})
|
||||||
|
|
||||||
beforeDestroy() {
|
onBeforeUnmount(() => {
|
||||||
this.observer.disconnect()
|
observer.value!.disconnect()
|
||||||
},
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="rootElem"
|
||||||
|
class="lazy-renderer"
|
||||||
|
:style="`min-height: ${fixedMinHeight}px`"
|
||||||
|
>
|
||||||
|
<slot v-if="render" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<BListGroup
|
<BListGroup
|
||||||
v-bind="$attrs"
|
ref="rootElem"
|
||||||
flush
|
flush
|
||||||
:class="{ 'fixed-height': fixedHeight, bordered: bordered }"
|
:class="{ 'fixed-height': fixedHeight, bordered: bordered }"
|
||||||
@scroll="onScroll"
|
@scroll="onScroll"
|
||||||
>
|
>
|
||||||
<YListGroupItem
|
<YListGroupItem
|
||||||
v-if="limit && messages.length > limit"
|
v-if="limit && messages.length > limit"
|
||||||
variant="info"
|
|
||||||
v-t="'api.partial_logs'"
|
v-t="'api.partial_logs'"
|
||||||
|
variant="info"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<YListGroupItem
|
<YListGroupItem
|
||||||
v-for="({ color, text }, i) in reducedMessages"
|
v-for="({ variant, text }, i) in reducedMessages"
|
||||||
:key="i"
|
:key="i"
|
||||||
:variant="color"
|
:variant="variant"
|
||||||
size="xs"
|
size="xs"
|
||||||
>
|
>
|
||||||
<span v-html="text" />
|
<span v-html="text" />
|
||||||
|
@ -22,55 +75,6 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.fixed-height {
|
.fixed-height {
|
||||||
max-height: 20vh;
|
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>
|
<template>
|
||||||
<div class="query-header w-100" v-on="$listeners" v-bind="$attrs">
|
<div class="query-header d-flex align-items-center w-100">
|
||||||
<!-- STATUS -->
|
|
||||||
<span
|
<span
|
||||||
class="status"
|
class="status"
|
||||||
:class="['bg-' + color, statusSize]"
|
:class="[`bg-${statusVariant}`, type]"
|
||||||
:aria-label="$t('api.query_status.' + request.status)"
|
:aria-label="$t(`api.query_status.${request.status}`)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- REQUEST DESCRIPTION -->
|
<!-- tabindex 0 on title for focus-trap when no tabable elements -->
|
||||||
<strong class="request-desc">
|
<strong :tabindex="type === 'overlay' ? 0 : undefined">
|
||||||
{{ request.humanRoute }}
|
{{ request.humanRoute }}
|
||||||
</strong>
|
</strong>
|
||||||
|
|
||||||
<div v-if="request.errors || request.warnings">
|
<div v-if="errors || warnings">
|
||||||
<!-- WEBSOCKET ERRORS COUNT -->
|
<span v-if="errors" class="ms-2">
|
||||||
<span class="count" v-if="request.errors">
|
{{ errors }}<YIcon iname="bug" class="text-danger ms-1" />
|
||||||
{{ request.errors }}<YIcon iname="bug" class="text-danger ml-1" />
|
|
||||||
</span>
|
</span>
|
||||||
<!-- WEBSOCKET WARNINGS COUNT -->
|
<span v-if="warnings" class="ms-2">
|
||||||
<span class="count" v-if="request.warnings">
|
{{ warnings }}<YIcon iname="warning" class="text-warning ms-1" />
|
||||||
{{ request.warnings
|
|
||||||
}}<YIcon iname="warning" class="text-warning ml-1" />
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VIEW ERROR BUTTON -->
|
<template v-if="type === 'history'">
|
||||||
<BButton
|
<BButton
|
||||||
v-if="showError && request.error"
|
v-if="request.err"
|
||||||
size="sm"
|
size="sm"
|
||||||
pill
|
pill
|
||||||
class="error-btn ml-auto py-0"
|
class="error-btn ms-auto py-0"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@click="reviewError"
|
@click.stop="emit('showError', request.id)"
|
||||||
>
|
>
|
||||||
<small v-t="'api_error.view_error'" />
|
<small v-t="'api_error.view_error'" />
|
||||||
</BButton>
|
</BButton>
|
||||||
|
|
||||||
<!-- TIME DISPLAY -->
|
<time :datetime="hour" :class="request.err ? 'ms-2' : 'ms-auto'">
|
||||||
<time
|
{{ hour }}
|
||||||
v-if="showTime"
|
|
||||||
:datetime="hour(request.date)"
|
|
||||||
:class="request.error ? 'ml-2' : 'ml-auto'"
|
|
||||||
>
|
|
||||||
{{ hour(request.date) }}
|
|
||||||
</time>
|
</time>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
div {
|
.query-header {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: $font-size-sm;
|
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 {
|
.error-btn {
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -107,35 +91,8 @@ div {
|
||||||
min-width: 70px;
|
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 {
|
time {
|
||||||
min-width: 3.5rem;
|
min-width: 3rem;
|
||||||
text-align: right;
|
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>
|
</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>
|
<template>
|
||||||
<BListGroup :flush="flush" :style="{ '--depth': tree.depth }">
|
<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
|
<BListGroupItem
|
||||||
:key="node.id"
|
|
||||||
class="list-group-item-action"
|
class="list-group-item-action"
|
||||||
:class="getClasses(node, i)"
|
:class="getClasses(node, i)"
|
||||||
@click="$router.push(node.data.to)"
|
@click="$router.push(node.data.to)"
|
||||||
>
|
>
|
||||||
<slot name="default" v-bind="node" />
|
<slot name="default" v-bind="node as NodeSlot" />
|
||||||
|
|
||||||
<BButton
|
<BButton
|
||||||
v-if="node.children"
|
v-if="node.height > 0"
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
:aria-expanded="node.data.opened ? 'true' : 'false'"
|
:aria-expanded="node.data.opened ? 'true' : 'false'"
|
||||||
:aria-controls="'collapse-' + node.id"
|
:aria-controls="'collapse-' + node.id"
|
||||||
:class="node.data.opened ? 'not-collapsed' : 'collapsed'"
|
:class="node.data.opened ? 'not-collapsed' : 'collapsed'"
|
||||||
class="ml-2"
|
class="ms-2"
|
||||||
@click.stop="node.data.opened = !node.data.opened"
|
@click.stop="node.data.opened = !node.data.opened"
|
||||||
>
|
>
|
||||||
<span class="sr-only">{{ toggleText }}</span>
|
<span class="visually-hidden">{{ toggleText }}</span>
|
||||||
<YIcon iname="chevron-right" />
|
<YIcon iname="chevron-right" />
|
||||||
</BButton>
|
</BButton>
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
|
|
||||||
<BCollapse
|
<BCollapse
|
||||||
v-if="node.children"
|
v-if="node.height > 0"
|
||||||
:key="'collapse-' + node.id"
|
|
||||||
v-model="node.data.opened"
|
|
||||||
:id="'collapse-' + node.id"
|
:id="'collapse-' + node.id"
|
||||||
|
v-model="node.data.opened"
|
||||||
>
|
>
|
||||||
<RecursiveListGroup
|
<RecursiveListGroup
|
||||||
:tree="node"
|
:tree="node"
|
||||||
|
@ -36,7 +72,7 @@
|
||||||
flush
|
flush
|
||||||
>
|
>
|
||||||
<!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT -->
|
<!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT -->
|
||||||
<template slot="default" slot-scope="scope">
|
<template #default="scope">
|
||||||
<slot name="default" v-bind="scope" />
|
<slot name="default" v-bind="scope" />
|
||||||
</template>
|
</template>
|
||||||
</RecursiveListGroup>
|
</RecursiveListGroup>
|
||||||
|
@ -45,31 +81,6 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.list-group {
|
.list-group {
|
||||||
.collapse {
|
.collapse {
|
||||||
|
@ -114,8 +125,13 @@ export default {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background-color: $list-group-hover-bg;
|
background-color: $list-group-hover-bg;
|
||||||
|
|
||||||
@include hover-focus() {
|
&:hover,
|
||||||
background-color: darken($list-group-hover-bg, 3%);
|
&: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>
|
<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>
|
<template #default>
|
||||||
|
<slot name="top" />
|
||||||
|
|
||||||
<slot name="disclaimer" />
|
<slot name="disclaimer" />
|
||||||
|
|
||||||
|
<slot name="before-form" />
|
||||||
|
|
||||||
<BForm
|
<BForm
|
||||||
:id="id"
|
:id="id"
|
||||||
:inline="inline"
|
:inline="inline"
|
||||||
:class="formClasses"
|
:class="formClasses"
|
||||||
@submit.prevent="onSubmit"
|
|
||||||
novalidate
|
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">
|
<slot name="server-error">
|
||||||
<BAlert
|
<YAlert
|
||||||
|
v-if="globalErrorFeedback !== ''"
|
||||||
|
alert
|
||||||
variant="danger"
|
variant="danger"
|
||||||
class="my-3"
|
class="my-3"
|
||||||
icon="ban"
|
icon="ban"
|
||||||
:show="errorFeedback !== ''"
|
|
||||||
>
|
>
|
||||||
<div v-html="errorFeedback" />
|
<div v-html="globalErrorFeedback" />
|
||||||
</BAlert>
|
</YAlert>
|
||||||
</slot>
|
</slot>
|
||||||
</BForm>
|
</BForm>
|
||||||
|
|
||||||
|
<slot name="after-form" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="!noFooter" #buttons>
|
<template v-if="!noFooter" #buttons>
|
||||||
<slot name="buttons">
|
<slot name="buttons">
|
||||||
<BButton type="submit" variant="success" :form="id">
|
<BButton type="submit" variant="success" :form="id">
|
||||||
{{ submitText ? submitText : $t('save') }}
|
{{ submitText ?? $t('save') }}
|
||||||
</BButton>
|
</BButton>
|
||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
</YCard>
|
</YCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<style lang="scss" scoped>
|
||||||
export default {
|
.card-title {
|
||||||
name: 'CardForm',
|
margin-bottom: 1em;
|
||||||
|
border-bottom: solid $border-width $gray-500;
|
||||||
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>
|
.form-section:not(:last-child) {
|
||||||
|
margin-bottom: 3rem;
|
||||||
<style lang="scss"></style>
|
}
|
||||||
|
</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>
|
<template>
|
||||||
<BRow no-gutters class="description-row">
|
<BRow no-gutters class="description-row">
|
||||||
<BCol v-bind="cols_">
|
<BCol v-bind="cols">
|
||||||
<slot name="term">
|
<slot name="term">
|
||||||
<strong>{{ term }}</strong>
|
<strong>{{ term }}</strong>
|
||||||
</slot>
|
</slot>
|
||||||
|
@ -14,24 +39,6 @@
|
||||||
</BRow>
|
</BRow>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.description-row {
|
.description-row {
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
|
@ -42,7 +49,7 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(md) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&:not(:last-of-type) {
|
&: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>
|
<template>
|
||||||
<span class="explain-what">
|
<span class="explain-what">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
<span class="explain-what-popover-container">
|
<span class="explain-what-popover-container">
|
||||||
<BButton :id="id" href="#" variant="light">
|
<BButton
|
||||||
|
variant="light"
|
||||||
|
@focus="open = true"
|
||||||
|
@blur="open = false"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
<YIcon iname="question" />
|
<YIcon iname="question" />
|
||||||
<span class="sr-only">
|
<span class="visually-hidden">
|
||||||
{{ $t('details_about', { subject: title }) }}
|
{{ $t('details_about', { subject: title }) }}
|
||||||
</span>
|
</span>
|
||||||
</BButton>
|
</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
|
<BPopover
|
||||||
placement="auto"
|
v-model="open"
|
||||||
:target="id"
|
placement="top"
|
||||||
triggers="focus"
|
|
||||||
custom-class="explain-what-popover"
|
custom-class="explain-what-popover"
|
||||||
:variant="variant"
|
:variant="variant"
|
||||||
:title="title"
|
:title="title"
|
||||||
|
@ -22,43 +46,36 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.explain-what {
|
.explain-what {
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-left: 0.1rem;
|
margin-left: 0.25rem;
|
||||||
border-radius: 50rem;
|
border-radius: 50rem;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-popover {
|
:deep() {
|
||||||
background-color: $white;
|
.popover {
|
||||||
|
background-color: $gray-800;
|
||||||
|
color: $black;
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
|
|
||||||
::v-deep .popover-body {
|
[data-bs-theme='dark'] & {
|
||||||
color: $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>
|
<template>
|
||||||
<!-- v-bind="$attrs" allow to pass default attrs not specified in this component slots -->
|
<DefineTemplate v-slot="{ ariaDescribedby }">
|
||||||
<BFormGroup
|
|
||||||
v-bind="attrs"
|
|
||||||
:id="_id"
|
|
||||||
:label-for="$attrs['label-for'] || props.id"
|
|
||||||
:state="state"
|
|
||||||
@touch="touch"
|
|
||||||
>
|
|
||||||
<!-- Make field props and state available as scoped slot data -->
|
<!-- 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 -->
|
<!-- if no component was passed as slot, render a component from the props -->
|
||||||
<Component
|
<Component
|
||||||
:is="component"
|
v-bind="props.cProps"
|
||||||
v-bind="props"
|
:is="props.component"
|
||||||
v-on="$listeners"
|
v-model="modelValue"
|
||||||
:value="value"
|
:aria-describedby="ariaDescribedby"
|
||||||
:state="state"
|
:state="state"
|
||||||
:required="validation ? 'required' in validation : false"
|
:validation="validation"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</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>
|
<template #invalid-feedback>
|
||||||
<span v-html="errorMessage" />
|
<span v-html="errorMessage" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #description>
|
<template v-if="description || link || 'description' in slots" #description>
|
||||||
<!-- Render description -->
|
<!-- Render description -->
|
||||||
<template v-if="description || link">
|
<template v-if="description || link">
|
||||||
<div class="d-flex">
|
<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 }}
|
{{ link.text }}
|
||||||
</BLink>
|
</BLink>
|
||||||
</div>
|
</div>
|
||||||
|
@ -36,7 +193,6 @@
|
||||||
<VueShowdown
|
<VueShowdown
|
||||||
v-if="description"
|
v-if="description"
|
||||||
:markdown="description"
|
:markdown="description"
|
||||||
flavor="github"
|
|
||||||
:class="{
|
:class="{
|
||||||
['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant,
|
['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant,
|
||||||
}"
|
}"
|
||||||
|
@ -48,97 +204,8 @@
|
||||||
</BFormGroup>
|
</BFormGroup>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
::v-deep .invalid-feedback code {
|
:deep(.invalid-feedback code) {
|
||||||
background-color: $gray-200;
|
background-color: $gray-200;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<template>
|
||||||
<BButtonToolbar :aria-label="label" id="top-bar">
|
<BButtonToolbar id="top-bar" :aria-label="label">
|
||||||
<div id="top-bar-left" class="top-bar-group" v-if="hasLeftSlot">
|
<div v-if="slots['group-left']" id="top-bar-left" class="top-bar-group">
|
||||||
<slot name="group-left" />
|
<slot name="group-left" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="top-bar-right" class="top-bar-group" v-if="hasRightSlot || button">
|
<div
|
||||||
<slot v-if="hasRightSlot" name="group-right" />
|
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 }}
|
<YIcon v-if="button.icon" :iname="button.icon" /> {{ button.text }}
|
||||||
</BButton>
|
</BButton>
|
||||||
</div>
|
</div>
|
||||||
</BButtonToolbar>
|
</BButtonToolbar>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
#top-bar {
|
#top-bar {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
@ -55,19 +42,19 @@ export default {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(xs) {
|
@include media-breakpoint-down(sm) {
|
||||||
.top-bar-group {
|
.top-bar-group {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(md) {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
|
|
||||||
#top-bar-right {
|
#top-bar-right {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
::v-deep > * {
|
:deep(> *) {
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,7 +75,7 @@ export default {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep .btn {
|
:deep(.btn) {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
&.dropdown-toggle-split {
|
&.dropdown-toggle-split {
|
||||||
margin-left: 0;
|
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 @@
|
||||||
|
<script setup lang="ts" generic="T extends Obj | AnyTreeNode">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { AnyTreeNode } from '@/helpers/data/tree'
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
items?: T[] | null
|
||||||
|
itemsName: string | null
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
items: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
<template>
|
||||||
<ViewBase v-bind="$attrs" v-on="$listeners" :skeleton="skeleton">
|
<div>
|
||||||
<template v-if="hasCustomTopBar" #top-bar>
|
<slot v-if="slots['top-bar']" name="top-bar" />
|
||||||
<slot name="top-bar" />
|
<TopBar v-else>
|
||||||
</template>
|
<template #group-left>
|
||||||
<template v-if="!hasCustomTopBar" #top-bar-group-left>
|
|
||||||
<BInputGroup class="w-100">
|
<BInputGroup class="w-100">
|
||||||
<BInputGroupPrepend is-text>
|
<BInputGroupText>
|
||||||
<YIcon iname="search" />
|
<YIcon iname="search" />
|
||||||
</BInputGroupPrepend>
|
</BInputGroupText>
|
||||||
|
|
||||||
<BFormInput
|
<BFormInput
|
||||||
id="top-bar-search"
|
id="top-bar-search"
|
||||||
:value="search"
|
v-model="model"
|
||||||
@input="$emit('update:search', $event)"
|
|
||||||
:placeholder="
|
:placeholder="
|
||||||
$t('search.for', { items: $tc('items.' + itemsName, 2) })
|
t('search.for', { items: t('items.' + itemsName, 2) })
|
||||||
"
|
"
|
||||||
:disabled="!items"
|
:disabled="items === undefined"
|
||||||
/>
|
/>
|
||||||
</BInputGroup>
|
</BInputGroup>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="!hasCustomTopBar" #top-bar-group-right>
|
<template #group-right>
|
||||||
<slot name="top-bar-buttons" />
|
<slot name="top-bar-buttons" />
|
||||||
</template>
|
</template>
|
||||||
|
</TopBar>
|
||||||
|
|
||||||
<template #top>
|
|
||||||
<slot name="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 name="forced-default">
|
||||||
|
<YAlert
|
||||||
|
v-if="noItemsMessage"
|
||||||
|
alert
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
variant="warning"
|
||||||
|
>
|
||||||
|
{{ noItemsMessage }}
|
||||||
|
</YAlert>
|
||||||
<slot v-else name="default" />
|
<slot v-else name="default" />
|
||||||
</template>
|
</slot>
|
||||||
|
|
||||||
<template #bot>
|
|
||||||
<slot name="bot" />
|
<slot name="bot" />
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<template #skeleton>
|
|
||||||
<slot name="skeleton" />
|
|
||||||
</template>
|
|
||||||
</ViewBase>
|
|
||||||
</template>
|
</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' },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
hasCustomTopBar() {
|
|
||||||
return 'top-bar' in this.$slots
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<Component
|
<Component
|
||||||
v-bind="$attrs"
|
:is="alert ? BAlert : 'div'"
|
||||||
:is="alert ? 'BAlert' : 'div'"
|
:model-value="alert ? true : undefined"
|
||||||
:variant="alert ? variant : null"
|
:variant="alert ? variant : undefined"
|
||||||
:class="{ ['alert alert-' + variant]: !alert }"
|
:class="{ ['alert alert-' + variant]: !alert }"
|
||||||
class="yuno-alert d-flex flex-column flex-md-row align-items-center"
|
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">
|
<div class="w-100">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</Component>
|
</Component>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<BBreadcrumb v-if="breadcrumb.length">
|
<BBreadcrumb v-if="breadcrumb.length">
|
||||||
<BBreadcrumbItem to="/">
|
<BBreadcrumbItem to="/">
|
||||||
<span class="sr-only">{{ $t('home') }}</span>
|
<span class="visually-hidden">{{ $t('home') }}</span>
|
||||||
<YIcon iname="home" />
|
<YIcon iname="home" />
|
||||||
</BBreadcrumbItem>
|
</BBreadcrumbItem>
|
||||||
|
|
||||||
<BBreadcrumbItem
|
<BBreadcrumbItem
|
||||||
v-for="({ name, text }, i) in breadcrumb"
|
v-for="({ to, text }, i) in breadcrumb"
|
||||||
:key="name"
|
:key="i"
|
||||||
:to="{ name }"
|
:to="to"
|
||||||
:active="i === breadcrumb.length - 1"
|
:active="i === breadcrumb.length - 1"
|
||||||
>
|
>
|
||||||
{{ text }}
|
{{ text }}
|
||||||
|
@ -16,18 +25,6 @@
|
||||||
</BBreadcrumb>
|
</BBreadcrumb>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'YBreadcrumb',
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['breadcrumb']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
border: none;
|
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>
|
<template>
|
||||||
<BCard v-bind="$attrs" :no-body="collapsable ? true : $attrs['no-body']">
|
<BCard
|
||||||
<template #header>
|
: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">
|
<div class="w-100 d-flex align-items-center flex-wrap custom-header">
|
||||||
<slot name="header">
|
<slot name="header">
|
||||||
<Component :is="titleTag" class="custom-header-title">
|
<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>
|
</Component>
|
||||||
<slot name="header-next" />
|
<slot name="header-next" />
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="hasButtons"
|
v-if="slots['header-buttons']"
|
||||||
class="mt-2 w-100 custom-header-buttons"
|
class="mt-2 w-100 custom-header-buttons"
|
||||||
:class="{
|
:class="{
|
||||||
[`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
|
[`ms-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
|
||||||
buttonUnbreak,
|
buttonUnbreak,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
@ -22,24 +65,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BButton
|
<BButton
|
||||||
v-if="collapsable"
|
v-if="collapsible"
|
||||||
@click="visible = !visible"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
class="align-self-center ml-auto"
|
class="align-self-center ms-auto"
|
||||||
:class="{
|
:class="{
|
||||||
'not-collapsed': visible,
|
'not-collapsed': visible,
|
||||||
collapsed: !visible,
|
collapsed: !visible,
|
||||||
[`ml-${buttonUnbreak}-2`]: buttonUnbreak,
|
[`ms-${buttonUnbreak}-2`]: buttonUnbreak,
|
||||||
}"
|
}"
|
||||||
|
@click="visible = !visible"
|
||||||
>
|
>
|
||||||
<YIcon iname="chevron-right" />
|
<YIcon iname="chevron-right" />
|
||||||
<span class="sr-only">{{ $t('words.collapse') }}</span>
|
<span class="visually-hidden">{{ $t('words.collapse') }}</span>
|
||||||
</BButton>
|
</BButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<BCollapse v-if="collapsable" :visible="visible">
|
<BCollapse v-if="collapsible" :visible="visible">
|
||||||
<slot v-if="'no-body' in $attrs" name="default" />
|
<slot v-if="noBody" name="default" />
|
||||||
<BCardBody v-else>
|
<BCardBody v-else>
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</BCardBody>
|
</BCardBody>
|
||||||
|
@ -48,42 +91,14 @@
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer v-if="'buttons' in $slots">
|
<template v-if="slots['buttons']" #footer>
|
||||||
<slot name="buttons" />
|
<slot name="buttons" />
|
||||||
</template>
|
</template>
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.card-header {
|
:deep(.card-header) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
.custom-header {
|
.custom-header {
|
||||||
|
@ -97,7 +112,7 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer {
|
:deep(.card-footer) {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -106,7 +121,7 @@ export default {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.collapse:not(.show) + .card-footer {
|
:deep(.collapse:not(.show) + .card-footer) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ColorVariant } from 'bootstrap-vue-next'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
iname: string
|
||||||
|
variant?: ColorVariant
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']"
|
:class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']"
|
||||||
|
@ -5,16 +14,6 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'YIcon',
|
|
||||||
props: {
|
|
||||||
iname: { type: String, required: true },
|
|
||||||
variant: { type: String, default: null },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.icon {
|
.icon {
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
@ -48,7 +47,7 @@ export default {
|
||||||
@each $color, $value in $theme-colors {
|
@each $color, $value in $theme-colors {
|
||||||
&.#{$color} {
|
&.#{$color} {
|
||||||
background-color: $value;
|
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>
|
<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">
|
<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>
|
||||||
|
|
||||||
<div class="yuno-list-group-item-content">
|
<div class="yuno-list-group-item-content">
|
||||||
|
@ -10,38 +49,6 @@
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.yuno-list-group-item {
|
.yuno-list-group-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -61,15 +68,15 @@ export default {
|
||||||
|
|
||||||
@each $color, $value in $theme-colors {
|
@each $color, $value in $theme-colors {
|
||||||
&-#{$color} {
|
&-#{$color} {
|
||||||
color: theme-color-level($color, 6);
|
color: tint-color($value, 50%);
|
||||||
|
|
||||||
[dark-theme='true'] & {
|
[data-bs-theme='light'] & {
|
||||||
color: theme-color-level($color, -6);
|
color: shade-color($value, 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yuno-list-group-item-status {
|
.yuno-list-group-item-status {
|
||||||
background-color: $value;
|
background-color: $value;
|
||||||
color: color-yiq($value);
|
color: color-contrast($value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -96,9 +103,9 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.yuno-list-group-item-content {
|
// .yuno-list-group-item-content {
|
||||||
color: $black;
|
// color: $black;
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
&-faded > * {
|
&-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>
|
<template>
|
||||||
<div :class="['custom-spinner', spinner]" />
|
<div :class="['custom-spinner', spinner]" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'YSpinner',
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['spinner']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.custom-spinner {
|
.custom-spinner {
|
||||||
animation: 8s linear infinite;
|
animation: 8s linear infinite;
|
||||||
|
@ -25,7 +19,7 @@ export default {
|
||||||
background-image: url('../../assets/spinners/pacman_dark.gif');
|
background-image: url('../../assets/spinners/pacman_dark.gif');
|
||||||
animation-name: back-and-forth-pacman;
|
animation-name: back-and-forth-pacman;
|
||||||
|
|
||||||
[dark-theme='true'] & {
|
[data-bs-theme='dark'] & {
|
||||||
background-image: url('../../assets/spinners/pacman_light.gif');
|
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,30 +1,19 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<BButton
|
import { computed, toValue } from 'vue'
|
||||||
:id="id"
|
|
||||||
:variant="type"
|
|
||||||
@click="$emit('action', $event)"
|
|
||||||
:disabled="!enabled"
|
|
||||||
class="d-block mb-3"
|
|
||||||
>
|
|
||||||
<YIcon :iname="icon_" class="mr-2" />
|
|
||||||
<span v-html="label" />
|
|
||||||
</BButton>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
import type { ButtonItemProps } from '@/types/form'
|
||||||
export default {
|
|
||||||
name: 'ButtonItem',
|
|
||||||
|
|
||||||
props: {
|
const props = withDefaults(defineProps<ButtonItemProps>(), {
|
||||||
label: { type: String, default: null },
|
enabled: true,
|
||||||
id: { type: String, default: null },
|
icon: undefined,
|
||||||
type: { type: String, default: 'success' },
|
type: 'success',
|
||||||
icon: { type: String, default: null },
|
})
|
||||||
enabled: { type: [Boolean, String], default: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
const emit = defineEmits<{
|
||||||
icon_() {
|
action: [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
const icons = {
|
const icons = {
|
||||||
success: 'thumbs-up',
|
success: 'thumbs-up',
|
||||||
info: 'info',
|
info: 'info',
|
||||||
|
@ -32,8 +21,19 @@ export default {
|
||||||
danger: 'times',
|
danger: 'times',
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.icon || icons[this.type]
|
return props.icon || icons[props.type]
|
||||||
},
|
})
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BButton
|
||||||
|
:id="id"
|
||||||
|
:variant="type"
|
||||||
|
:disabled="!toValue(enabled)"
|
||||||
|
class="d-block mb-3"
|
||||||
|
@click="emit('action', id)"
|
||||||
|
>
|
||||||
|
<YIcon :iname="icon" class="me-2" />
|
||||||
|
<span v-html="label" />
|
||||||
|
</BButton>
|
||||||
|
</template>
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<BFormCheckbox
|
<BFormCheckbox
|
||||||
v-model="checked"
|
|
||||||
v-on="$listeners"
|
|
||||||
:id="id"
|
:id="id"
|
||||||
:aria-describedby="$parent.id + '__BV_description_'"
|
v-model="modelValue"
|
||||||
|
:name="name"
|
||||||
|
:aria-describedby="ariaDescribedby"
|
||||||
|
:state="state"
|
||||||
switch
|
switch
|
||||||
>
|
>
|
||||||
{{ label || $t(labels[checked]) }}
|
{{ label || $t(labels[modelValue ? 'true' : 'false']) }}
|
||||||
</BFormCheckbox>
|
</BFormCheckbox>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div>
|
<div :id="id">
|
||||||
<p v-text="label" />
|
<p v-text="label" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'DisplayTextItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: null },
|
|
||||||
label: { type: String, default: null },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,86 +1,108 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<BButtonGroup class="w-100">
|
import type { BFormFile } from 'bootstrap-vue-next'
|
||||||
<BButton
|
import { computed, inject, ref } from 'vue'
|
||||||
v-if="!this.required && this.value.file !== null"
|
|
||||||
@click="clearFiles"
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
<span class="sr-only">{{ $t('delete') }}</span>
|
|
||||||
<YIcon iname="trash" />
|
|
||||||
</BButton>
|
|
||||||
|
|
||||||
<BFormFile
|
import { ValidationTouchSymbol } from '@/composables/form'
|
||||||
: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 { getFileContent } from '@/helpers/commons'
|
import { getFileContent } from '@/helpers/commons'
|
||||||
|
import type {
|
||||||
|
BaseItemComputedProps,
|
||||||
|
FileItemProps,
|
||||||
|
FileModelValue,
|
||||||
|
} from '@/types/form'
|
||||||
|
|
||||||
export default {
|
const props = withDefaults(
|
||||||
name: 'FileItem',
|
defineProps<FileItemProps & BaseItemComputedProps>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
placeholder: 'Choose a file or drop it here...',
|
||||||
|
touchKey: undefined,
|
||||||
|
accept: '',
|
||||||
|
dropPlaceholder: undefined,
|
||||||
|
|
||||||
props: {
|
ariaDescribedby: undefined,
|
||||||
id: { type: String, default: null },
|
state: undefined,
|
||||||
value: { type: Object, default: () => ({ file: null }) },
|
validation: undefined,
|
||||||
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 },
|
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
|
||||||
computed: {
|
const emit = defineEmits<{
|
||||||
_placeholder: function () {
|
'update:modelValue': [value: FileModelValue]
|
||||||
return this.value.file === null ? this.placeholder : this.value.file.name
|
}>()
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
const modelValue = defineModel<FileModelValue>({
|
||||||
onInput(file) {
|
default: () => ({ file: null }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const touch = inject(ValidationTouchSymbol)
|
||||||
|
const inputElem = ref<InstanceType<typeof BFormFile> | null>(null)
|
||||||
|
|
||||||
|
const placeholder = computed(() => {
|
||||||
|
return modelValue.value.file === null
|
||||||
|
? props.placeholder
|
||||||
|
: modelValue.value.file.name
|
||||||
|
})
|
||||||
|
|
||||||
|
function onInput(file: File | File[] | null) {
|
||||||
const value = {
|
const value = {
|
||||||
file,
|
file: file as File | null,
|
||||||
content: '',
|
content: file !== null ? '' : null,
|
||||||
current: false,
|
current: false,
|
||||||
removed: false,
|
removed: false,
|
||||||
}
|
}
|
||||||
// Update the value with the new File and an empty content for now
|
// Update the value with the new File and an empty content for now
|
||||||
this.$emit('input', value)
|
emit('update:modelValue', value)
|
||||||
|
|
||||||
// Asynchronously load the File content and update the value again
|
// Asynchronously load the File content and update the value again
|
||||||
getFileContent(file).then((content) => {
|
getFileContent(file as File).then((content) => {
|
||||||
this.$emit('input', { ...value, content })
|
emit('update:modelValue', { ...value, content })
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
|
|
||||||
clearFiles() {
|
function clearFiles() {
|
||||||
this.$refs['input-file'].reset()
|
inputElem.value!.reset()
|
||||||
this.$emit('input', {
|
emit('update:modelValue', {
|
||||||
file: null,
|
file: null,
|
||||||
content: '',
|
content: '',
|
||||||
current: false,
|
current: false,
|
||||||
removed: true,
|
removed: true,
|
||||||
})
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const required = computed(() => 'required' in (props.validation ?? {}))
|
||||||
</script>
|
</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>
|
<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;
|
color: $input-placeholder-color;
|
||||||
|
|
||||||
.btn-danger + .b-form-file & {
|
.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>
|
<template>
|
||||||
<BFormInput
|
<BFormInput
|
||||||
:value="value"
|
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-bind="fromValidation"
|
||||||
|
v-model="modelValue"
|
||||||
|
:name="name"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:type="type"
|
:autocomplete="autocomplete"
|
||||||
:state="state"
|
|
||||||
:required="required"
|
|
||||||
:min="min"
|
|
||||||
:max="max"
|
|
||||||
:step="step"
|
:step="step"
|
||||||
:trim="trim"
|
:trim="trim"
|
||||||
:autocomplete="autocomplete_"
|
:type="type"
|
||||||
v-on="$listeners"
|
:aria-describedby="ariaDescribedby"
|
||||||
@blur="$parent.$emit('touch', name)"
|
:state="state"
|
||||||
|
@blur="touch?.(touchKey)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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>
|
<script setup lang="ts">
|
||||||
<VueShowdown :markdown="label" flavor="github" />
|
import type { MarkdownItemProps } from '@/types/form'
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
withDefaults(defineProps<MarkdownItemProps>(), {
|
||||||
export default {
|
id: undefined,
|
||||||
name: 'MarkdownItem',
|
})
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: null },
|
|
||||||
label: { type: String, default: null },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VueShowdown :id="id" :markdown="label" />
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,41 +1,30 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<BAlert
|
import { computed } from 'vue'
|
||||||
class="d-flex flex-column flex-md-row align-items-center"
|
|
||||||
:variant="type"
|
|
||||||
show
|
|
||||||
>
|
|
||||||
<YIcon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" />
|
|
||||||
|
|
||||||
<VueShowdown
|
import type { ReadOnlyAlertItemProps } from '@/types/form'
|
||||||
:markdown="label"
|
|
||||||
flavor="github"
|
|
||||||
tag="span"
|
|
||||||
class="markdown"
|
|
||||||
/>
|
|
||||||
</BAlert>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
const props = withDefaults(defineProps<ReadOnlyAlertItemProps>(), {
|
||||||
export default {
|
id: undefined,
|
||||||
name: 'ReadOnlyAlertItem',
|
icon: undefined,
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
props: {
|
const icon = computed(() => {
|
||||||
id: { type: String, default: null },
|
// TODO merge with `DEFAULT_VARIANT_ICON`
|
||||||
label: { type: String, default: null },
|
|
||||||
type: { type: String, default: null },
|
|
||||||
icon: { type: String, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
icon_() {
|
|
||||||
const icons = {
|
const icons = {
|
||||||
success: 'thumbs-up',
|
success: 'thumbs-up',
|
||||||
info: 'info',
|
info: 'info',
|
||||||
warning: 'exclamation',
|
warning: 'exclamation',
|
||||||
danger: 'times',
|
danger: 'times',
|
||||||
}
|
}
|
||||||
return this.icon || icons[this.type]
|
|
||||||
},
|
return props.icon || icons[props.type]
|
||||||
},
|
})
|
||||||
}
|
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<BFormSelect
|
<BFormSelect
|
||||||
:value="value"
|
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="name"
|
||||||
:options="choices"
|
:options="choices"
|
||||||
|
:aria-describedby="ariaDescribedby"
|
||||||
|
:state="state"
|
||||||
:required="required"
|
:required="required"
|
||||||
v-on="$listeners"
|
@blur="touch?.(touchKey)"
|
||||||
@blur.native="$emit('blur', value)"
|
>
|
||||||
/>
|
<template v-if="!isOptionalSelectOption" #first>
|
||||||
|
<BFormSelectOption value="null" :disabled="required">
|
||||||
|
-- {{ required ? $t('select_an_option') : $t('words.none') }} --
|
||||||
|
</BFormSelectOption>
|
||||||
|
</template>
|
||||||
|
</BFormSelect>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<BFormTags
|
<BFormTags
|
||||||
v-model="tags"
|
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-model="modelValue"
|
||||||
|
:name="name"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:required="required"
|
|
||||||
separator=" ,;"
|
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
remove-on-delete
|
:aria-describedby="ariaDescribedby"
|
||||||
:state="state"
|
:state="state"
|
||||||
:options="options"
|
:required="required"
|
||||||
v-on="$listeners"
|
remove-on-delete
|
||||||
@blur="$parent.$emit('touch', name)"
|
separator=" ,;"
|
||||||
|
@blur="touch?.(touchKey)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div class="tags-selectize">
|
<div class="tags-selectize">
|
||||||
<BFormTags
|
<BFormTags
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
v-on="$listeners"
|
|
||||||
:value="value"
|
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-model="modelValue"
|
||||||
|
:name="name"
|
||||||
|
:aria-describedby="ariaDescribedby"
|
||||||
|
:state="state"
|
||||||
|
no-outer-focus
|
||||||
size="lg"
|
size="lg"
|
||||||
class="p-0 border-0"
|
class="p-0 border-0"
|
||||||
no-outer-focus
|
|
||||||
>
|
>
|
||||||
<template #default="{ tags, disabled, addTag, removeTag }">
|
<template #default="{ tags, disabled, addTag, removeTag }">
|
||||||
<ul
|
<ul
|
||||||
|
@ -20,22 +138,22 @@
|
||||||
class="list-inline-item"
|
class="list-inline-item"
|
||||||
>
|
>
|
||||||
<BFormTag
|
<BFormTag
|
||||||
@remove="onRemoveTag({ option: tag, removeTag })"
|
|
||||||
:title="tag"
|
:title="tag"
|
||||||
:disabled="disabled || disabledItems.includes(tag)"
|
:disabled="disabled || (disabledItems?.includes(tag) ?? false)"
|
||||||
class="border border-dark mb-2"
|
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>
|
</BFormTag>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<BDropdown
|
<BDropdown
|
||||||
ref="dropdown"
|
ref="dropdownElem"
|
||||||
variant="outline-dark"
|
variant="outline-dark"
|
||||||
block
|
block
|
||||||
menu-class="w-100"
|
menu-class="w-100"
|
||||||
@keydown.native="onDropdownKeydown"
|
@keydown="onDropdownKeydown"
|
||||||
>
|
>
|
||||||
<template #button-content>
|
<template #button-content>
|
||||||
<YIcon iname="search-plus" /> {{ label }}
|
<YIcon iname="search-plus" /> {{ label }}
|
||||||
|
@ -44,26 +162,23 @@
|
||||||
<BDropdownGroup class="search-group">
|
<BDropdownGroup class="search-group">
|
||||||
<BDropdownForm @submit.stop.prevent="() => {}">
|
<BDropdownForm @submit.stop.prevent="() => {}">
|
||||||
<BFormGroup
|
<BFormGroup
|
||||||
:label="$t('search.for', { items: itemsName })"
|
:label="searchI18n.label"
|
||||||
|
:label-for="id + '-search-input'"
|
||||||
label-cols-md="auto"
|
label-cols-md="auto"
|
||||||
label-size="sm"
|
label-size="sm"
|
||||||
:label-for="id + '-search-input'"
|
:invalid-feedback="searchI18n.invalidFeedback"
|
||||||
:invalid-feedback="
|
|
||||||
$tc('search.not_found', 0, {
|
|
||||||
items: $tc('items.' + itemsName, 0),
|
|
||||||
})
|
|
||||||
"
|
|
||||||
:state="searchState"
|
:state="searchState"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="mb-0"
|
class="mb-0"
|
||||||
>
|
>
|
||||||
<BFormInput
|
<BFormInput
|
||||||
ref="search-input"
|
|
||||||
v-model="search"
|
|
||||||
:id="id + '-search-input'"
|
:id="id + '-search-input'"
|
||||||
type="search"
|
ref="searchElem"
|
||||||
size="sm"
|
v-model="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
size="sm"
|
||||||
|
type="search"
|
||||||
|
@click.stop
|
||||||
/>
|
/>
|
||||||
</BFormGroup>
|
</BFormGroup>
|
||||||
</BDropdownForm>
|
</BDropdownForm>
|
||||||
|
@ -71,19 +186,15 @@
|
||||||
</BDropdownGroup>
|
</BDropdownGroup>
|
||||||
|
|
||||||
<BDropdownItemButton
|
<BDropdownItemButton
|
||||||
v-for="option in availableOptions"
|
v-for="(option, i) in availableOptions"
|
||||||
:key="option"
|
:key="i"
|
||||||
@click="onAddTag({ option, addTag })"
|
@click="onAddTag(option, addTag)"
|
||||||
>
|
>
|
||||||
{{ option }}
|
{{ typeof option === 'string' ? option : option.text }}
|
||||||
</BDropdownItemButton>
|
</BDropdownItemButton>
|
||||||
<BDropdownText v-if="!criteria && availableOptions.length === 0">
|
<BDropdownText v-if="!criteria && availableOptions.length === 0">
|
||||||
<YIcon iname="exclamation-triangle" />
|
<YIcon iname="exclamation-triangle" />
|
||||||
{{
|
{{ searchI18n.noItems }}
|
||||||
$tc('items_verbose_items_left', 0, {
|
|
||||||
items: $tc('items.' + itemsName, 0),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</BDropdownText>
|
</BDropdownText>
|
||||||
</BDropdown>
|
</BDropdown>
|
||||||
</template>
|
</template>
|
||||||
|
@ -91,92 +202,8 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
::v-deep .dropdown-menu {
|
:deep(.dropdown-menu) {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
|
@ -185,7 +212,14 @@ export default {
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background-color: $white;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME bvn fix (should be fixed in lib)
|
||||||
|
:deep(.btn-group) {
|
||||||
|
display: block;
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<template>
|
||||||
<BFormTextarea
|
<BFormTextarea
|
||||||
:value="value"
|
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-model="modelValue"
|
||||||
|
:name="name"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:required="required"
|
:aria-describedby="ariaDescribedby"
|
||||||
:state="state"
|
:state="state"
|
||||||
|
:required="required"
|
||||||
rows="4"
|
rows="4"
|
||||||
v-on="$listeners"
|
@blur="touch?.(touchKey)"
|
||||||
@blur="$parent.$emit('touch', name)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</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,11 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
import type { Cols } from '@/types/commons'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ itemCount?: number; cols: Cols }>(), {
|
||||||
|
itemCount: 5,
|
||||||
|
cols: () => ({ md: 4, lg: 2 }),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<BSkeletonWrapper>
|
||||||
<BCard>
|
<BCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
<BSkeleton width="30%" height="36px" class="m-0" />
|
<BSkeleton width="30%" height="26px" class="m-0" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-for="count in itemCount">
|
<template v-for="count in itemCount" :key="count">
|
||||||
<BRow :key="count" :class="{ 'd-block': cols === null }">
|
<BRow :class="{ 'd-block': cols === null }">
|
||||||
<BCol v-bind="cols">
|
<BCol v-bind="cols">
|
||||||
<div style="height: 38px" class="d-flex align-items-center">
|
<div style="height: 38px" class="d-flex align-items-center">
|
||||||
<BSkeleton
|
<BSkeleton
|
||||||
|
@ -18,12 +29,12 @@
|
||||||
|
|
||||||
<BCol>
|
<BCol>
|
||||||
<div
|
<div
|
||||||
class="w100 d-flex justify-content-between"
|
|
||||||
v-if="count % 2 === 0"
|
v-if="count % 2 === 0"
|
||||||
|
class="w100 d-flex justify-content-between"
|
||||||
>
|
>
|
||||||
<BSkeleton width="100%" height="38px" />
|
<BSkeleton width="100%" height="38px" />
|
||||||
|
|
||||||
<BSkeleton width="38px" height="38px" class="ml-2" />
|
<BSkeleton width="38px" height="38px" class="ms-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BSkeleton v-else width="100%" height="38px" />
|
<BSkeleton v-else width="100%" height="38px" />
|
||||||
|
@ -32,33 +43,14 @@
|
||||||
</BCol>
|
</BCol>
|
||||||
</BRow>
|
</BRow>
|
||||||
|
|
||||||
<hr :key="count + '-hr'" />
|
<hr />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="d-flex justify-content-end w-100">
|
<div class="d-flex justify-content-end w-100">
|
||||||
<BSkeleton width="100px" height="38px" />
|
<BSkeleton width="100px" height="36px" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BCard>
|
</BCard>
|
||||||
|
</BSkeletonWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CardFormSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
cols: {
|
|
||||||
type: [Object, null],
|
|
||||||
default() {
|
|
||||||
return { md: 4, lg: 2 }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<BSkeletonWrapper>
|
||||||
<BCard>
|
<BCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
<BSkeleton width="30%" height="36px" class="m-0" />
|
<BSkeleton width="30%" height="36px" class="m-0" />
|
||||||
|
@ -13,18 +20,5 @@
|
||||||
</BCol>
|
</BCol>
|
||||||
</BRow>
|
</BRow>
|
||||||
</BCard>
|
</BCard>
|
||||||
|
</BSkeletonWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CardInfoSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ itemCount: number; search: boolean }>(), {
|
||||||
|
itemCount: 5,
|
||||||
|
search: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<BSkeletonWrapper :search="search">
|
||||||
<BCard no-body>
|
<BCard no-body>
|
||||||
<template #header>
|
<template #header>
|
||||||
<BSkeleton width="30%" height="36px" class="m-0" />
|
<BSkeleton width="30%" height="36px" class="m-0" />
|
||||||
|
@ -10,25 +20,12 @@
|
||||||
<BSkeleton
|
<BSkeleton
|
||||||
:width="randint(50, 100) + '%'"
|
:width="randint(50, 100) + '%'"
|
||||||
height="24px"
|
height="24px"
|
||||||
class="mr-3"
|
class="me-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BSkeleton :width="randint(30, 80) + '%'" height="24px" class="m-0" />
|
<BSkeleton :width="randint(30, 80) + '%'" height="24px" class="m-0" />
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</BCard>
|
</BCard>
|
||||||
|
</BSkeletonWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CardListSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,22 +1,19 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{ itemCount: number; button: boolean; search: boolean }>(),
|
||||||
|
{ itemCount: 5, button: true, search: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<BSkeletonWrapper :button="button" :search="search">
|
||||||
<BListGroup>
|
<BListGroup>
|
||||||
<BListGroupItem v-for="count in itemCount" :key="count">
|
<BListGroupItem v-for="count in itemCount" :key="count">
|
||||||
<BSkeleton :width="randint(15, 25) + '%'" height="24px" class="mb-2" />
|
<BSkeleton :width="randint(15, 25) + '%'" height="24px" class="mb-2" />
|
||||||
<BSkeleton :width="randint(25, 50) + '%'" height="24px" class="m-0" />
|
<BSkeleton :width="randint(25, 50) + '%'" height="24px" class="m-0" />
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
|
</BSkeletonWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ListGroupSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
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.
|
* A Node that can have a parent and children.
|
||||||
*/
|
*/
|
||||||
export class Node {
|
class TreeNode {
|
||||||
constructor(data) {
|
data: TreeNodeData | null = null
|
||||||
this.data = data
|
depth: number = 0
|
||||||
this.depth = 0
|
height: number = 0
|
||||||
this.height = 0
|
parent: AnyTreeNode | null = null
|
||||||
this.parent = null
|
id: string = 'root'
|
||||||
// this.id = null
|
children: TreeChildNode[] = []
|
||||||
// this.children = null
|
_remove: boolean = false
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the specified `callback` for this node and each descendant in pre-order
|
* 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
|
* The specified function is passed the current descendant, the zero-based traversal
|
||||||
* index, and this node.
|
* index, and this node.
|
||||||
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachBefore.js.
|
* 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) {
|
eachBefore(
|
||||||
const nodes = []
|
callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => void,
|
||||||
|
) {
|
||||||
|
const root = this as TreeRootNode
|
||||||
|
const nodes: AnyTreeNode[] = []
|
||||||
let index = -1
|
let index = -1
|
||||||
let node = this
|
let node: AnyTreeNode | undefined = root
|
||||||
|
|
||||||
while (node) {
|
while (node) {
|
||||||
callback(node, ++index, this)
|
callback(node, ++index, root)
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
nodes.push(...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
|
* The specified function is passed the current descendant, the zero-based traversal
|
||||||
* index, and this node.
|
* index, and this node.
|
||||||
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachAfter.js.
|
* 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) {
|
eachAfter(
|
||||||
const nodes = []
|
callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => void,
|
||||||
const next = []
|
) {
|
||||||
let node = this
|
const root = this as TreeRootNode
|
||||||
|
const nodes: AnyTreeNode[] = []
|
||||||
|
const next: AnyTreeNode[] = []
|
||||||
|
let node: AnyTreeNode | undefined = root
|
||||||
|
|
||||||
while (node) {
|
while (node) {
|
||||||
next.push(node)
|
next.push(node)
|
||||||
|
@ -64,132 +72,117 @@ export class Node {
|
||||||
|
|
||||||
let index = 0
|
let index = 0
|
||||||
for (let i = next.length - 1; i >= 0; i--) {
|
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.
|
* Returns a deep copied and filtered tree of itself.
|
||||||
* Specified filter function is passed each nodes in post-order traversal and must
|
* Specified filter function is passed each nodes in post-order traversal and must
|
||||||
* return `true` or `false` like a regular filter function.
|
* 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)
|
// 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
|
// 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`.
|
// whole dupplicated node. Overwrite node's `data` by node's original `data`.
|
||||||
node.data = node.data.data
|
|
||||||
|
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
// Removed flagged children
|
// Removed flagged children
|
||||||
node.children = node.children.filter((child) => !child.remove)
|
node.children = node.children.filter((child) => !child._remove)
|
||||||
if (!node.children.length) delete node.children
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform filter callback on non-root nodes
|
// 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
|
// Flag node if there's no match in node nor in its children
|
||||||
if (!match && !node.children) {
|
if (!match && !node.children.length) {
|
||||||
node.remove = true
|
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`.
|
* Generates a new hierarchy from the specified tabular `dataset`.
|
||||||
* The specified `dataset` must be an array of objects that contains at least a
|
* The specified `dataset` must be an array of objects that contains at least a
|
||||||
* `name` property and an optional `parent` property referencing its parent `name`.
|
* `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.
|
* 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(
|
export function stratify(dataset: TreeNodeData[]) {
|
||||||
dataset,
|
const root = new TreeRootNode()
|
||||||
{ idKey = 'name', parentIdKey = 'parent' } = {},
|
const nodesMap: Map<TreeChildNode['id'], TreeChildNode> = new Map()
|
||||||
) {
|
|
||||||
const root = new Node(null, true)
|
|
||||||
root.children = []
|
|
||||||
const nodesMap = new Map()
|
|
||||||
|
|
||||||
// Creates all nodes that will be arranged in a hierarchy
|
// Creates all nodes that will be arranged in a hierarchy
|
||||||
const nodes = dataset.map((d) => {
|
dataset.map((d) => {
|
||||||
const node = new Node(d)
|
const parent = d.parent ? nodesMap.get(d.parent) || root : root
|
||||||
node.id = d[idKey]
|
const node = new TreeChildNode(d, parent)
|
||||||
|
parent.children.push(node)
|
||||||
nodesMap.set(node.id, node)
|
nodesMap.set(node.id, node)
|
||||||
if (d[parentIdKey]) {
|
|
||||||
node.parent = d[parentIdKey]
|
|
||||||
}
|
|
||||||
return node
|
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) => {
|
root.eachBefore((node) => {
|
||||||
// Compute node depth
|
// Compute node depth
|
||||||
if (node.parent) {
|
if (node.parent) {
|
||||||
node.depth = node.parent.depth + 1
|
node.depth = node.parent.depth + 1
|
||||||
// Remove parent key if parent is root (node with no data)
|
// Remove parent key if parent is root (node with no data)
|
||||||
if (!node.parent.data) delete node.parent
|
|
||||||
}
|
}
|
||||||
computeNodeHeight(node)
|
computeNodeHeight(node)
|
||||||
})
|
})
|
||||||
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a root node from the specified hierarchical `data`.
|
* Constructs a root node from the specified hierarchical `data`.
|
||||||
* The specified `data` must be an object representing the root node and its children.
|
* The specified `data` must be an object representing the root node and its children.
|
||||||
* 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.
|
* 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`)
|
* @param data - object representing a root node (a simple { id, children } object or a `TreeNode`)
|
||||||
* @return {Node}
|
|
||||||
*/
|
*/
|
||||||
export function hierarchy(data) {
|
export function hierarchy(data: TreeRootNode) {
|
||||||
const root = new Node(data)
|
function deepCopyNodes(nodes: TreeChildNode[], parent: AnyTreeNode) {
|
||||||
const nodes = []
|
return nodes.map((node) => {
|
||||||
let node = root
|
const copy = new TreeChildNode(node.data, parent)
|
||||||
|
copy.depth = parent.depth + 1
|
||||||
while (node) {
|
copy.children = deepCopyNodes(node.children, copy)
|
||||||
if (node.data.children) {
|
return copy
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const root = new TreeRootNode()
|
||||||
|
root.children = deepCopyNodes(data.children, root)
|
||||||
root.eachBefore(computeNodeHeight)
|
root.eachBefore(computeNodeHeight)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
@ -197,13 +190,12 @@ export function hierarchy(data) {
|
||||||
/**
|
/**
|
||||||
* Compute the node height by iterating on parents
|
* Compute the node height by iterating on parents
|
||||||
* Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62.
|
* Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62.
|
||||||
*
|
|
||||||
* @param {Node} node
|
|
||||||
*/
|
*/
|
||||||
function computeNodeHeight(node) {
|
function computeNodeHeight(node: TreeNode) {
|
||||||
|
let node_: TreeNode | null = node
|
||||||
let height = 0
|
let height = 0
|
||||||
do {
|
do {
|
||||||
node.height = height
|
node_.height = height
|
||||||
node = node.parent
|
node_ = node_.parent
|
||||||
} while (node && node.height < ++height)
|
} 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
|
// Unicode ranges are taken from https://stackoverflow.com/a/37668315
|
||||||
const nonAsciiWordCharacters =
|
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'
|
'\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(
|
const domain = helpers.regex(
|
||||||
'domain',
|
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`^(?:[\\da-z${nonAsciiWordCharacters}]+(?:-*[\\da-z${nonAsciiWordCharacters}]+)*\\.)+(?:(?:xn--)?[\\da-z${nonAsciiWordCharacters}]{2,})$`,
|
`^(?:[\\da-z${nonAsciiWordCharacters}]+(?:-*[\\da-z${nonAsciiWordCharacters}]+)*\\.)+(?:(?:xn--)?[\\da-z${nonAsciiWordCharacters}]{2,})$`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const dynDomain = helpers.regex(
|
const dynDomain = helpers.regex(
|
||||||
'dynDomain',
|
|
||||||
new RegExp(`^(?:xn--)?[\\da-z-${nonAsciiWordCharacters}]+$`),
|
new RegExp(`^(?:xn--)?[\\da-z-${nonAsciiWordCharacters}]+$`),
|
||||||
)
|
)
|
||||||
|
|
||||||
const emailLocalPart = helpers.regex('emailLocalPart', /^[\w+.-]+$/)
|
const emailLocalPart = helpers.regex(/^[\w+.-]+$/)
|
||||||
|
|
||||||
const emailForwardLocalPart = helpers.regex(
|
const emailForwardLocalPart = helpers.regex(/^[\w+.-]+$/)
|
||||||
'emailForwardLocalPart',
|
|
||||||
/^[\w+.-]+$/,
|
|
||||||
)
|
|
||||||
|
|
||||||
const email = (value) =>
|
const email = (value: string) => {
|
||||||
helpers.withParams({ type: 'email', value }, (value) => {
|
|
||||||
const [localPart, domainPart] = value.split('@')
|
const [localPart, domainPart] = value.split('@')
|
||||||
if (!domainPart) return !helpers.req(value) || false
|
if (!domainPart) return !helpers.req(value) || false
|
||||||
return (
|
return (
|
||||||
!helpers.req(value) || (emailLocalPart(localPart) && domain(domainPart))
|
!helpers.req(value) || (emailLocalPart(localPart) && domain(domainPart))
|
||||||
)
|
)
|
||||||
})(value)
|
}
|
||||||
|
|
||||||
// Same as email but with `+` allowed.
|
// Same as email but with `+` allowed.
|
||||||
const emailForward = (value) =>
|
const emailForward = (value: string) => {
|
||||||
helpers.withParams({ type: 'emailForward', value }, (value) => {
|
|
||||||
const [localPart, domainPart] = value.split('@')
|
const [localPart, domainPart] = value.split('@')
|
||||||
if (!domainPart) return !helpers.req(value) || false
|
if (!domainPart) return !helpers.req(value) || false
|
||||||
return (
|
return (
|
||||||
!helpers.req(value) ||
|
!helpers.req(value) ||
|
||||||
(emailForwardLocalPart(localPart) && domain(domainPart))
|
(emailForwardLocalPart(localPart) && domain(domainPart))
|
||||||
)
|
)
|
||||||
})(value)
|
}
|
||||||
|
|
||||||
const appRepoUrl = helpers.regex(
|
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)?\/?$/,
|
/^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(
|
const name = helpers.regex(
|
||||||
'name',
|
|
||||||
new RegExp(`^(?:[A-Za-z${nonAsciiWordCharacters}]{1,30}[ ,.'-]{0,3})+$`),
|
new RegExp(`^(?:[A-Za-z${nonAsciiWordCharacters}]{1,30}[ ,.'-]{0,3})+$`),
|
||||||
)
|
)
|
||||||
|
|
||||||
const unique = (items) => (item) =>
|
const unique = (items: MaybeRef<any[] | null>) =>
|
||||||
helpers.withParams({ type: 'unique', arg: items, value: item }, (item) =>
|
helpers.withParams({ type: 'unique', arg: toValue(items) }, (item) => {
|
||||||
items ? !helpers.req(item) || !items.includes(item) : true,
|
const items_ = toValue(items)
|
||||||
)(item)
|
return items_ ? !helpers.req(item) || !items_.includes(item) : true
|
||||||
|
})
|
||||||
|
|
||||||
export {
|
export {
|
||||||
alphalownumdot_,
|
alphalownumdot_,
|
||||||
|
@ -75,7 +65,6 @@ export {
|
||||||
emailForwardLocalPart,
|
emailForwardLocalPart,
|
||||||
emailLocalPart,
|
emailLocalPart,
|
||||||
appRepoUrl,
|
appRepoUrl,
|
||||||
includes,
|
|
||||||
name,
|
name,
|
||||||
unique,
|
unique,
|
||||||
}
|
}
|
|
@ -9,4 +9,4 @@ export {
|
||||||
minValue,
|
minValue,
|
||||||
required,
|
required,
|
||||||
sameAs,
|
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?"
|
"help": "Need help?"
|
||||||
},
|
},
|
||||||
"footer_version": "Powered by <a href='https://yunohost.org'>YunoHost</a> {version} ({repo}).",
|
"footer_version": "Powered by <a href='https://yunohost.org'>YunoHost</a> {version} ({repo}).",
|
||||||
|
"form": {
|
||||||
|
"select_one": "Please select an option"
|
||||||
|
},
|
||||||
"form_errors": {
|
"form_errors": {
|
||||||
"alpha": "Value must be alphabetical characters only.",
|
"alpha": "Value must be alphabetical characters only.",
|
||||||
"alphalownumdot_": "Value must be lower-case alphanumeric, dots and underscore 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.",
|
"invalid_form": "The form contains some errors.",
|
||||||
"maxValue": "Value must be a number equal or lesser than {max}.",
|
"maxValue": "Value must be a number equal or lesser than {max}.",
|
||||||
"minValue": "Value must be a number equal or greater than {min}.",
|
"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>",
|
"name": "Names may not includes special characters except <code> ,.'-</code>",
|
||||||
"notInUsers": "The user '{value}' already exists.",
|
"notInUsers": "The user '{value}' already exists.",
|
||||||
"number": "Value must be a number.",
|
"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.",
|
"label_for_manifestname_help": "This is the name displayed in the user portal. This can be changed later.",
|
||||||
"last_ran": "Last time ran:",
|
"last_ran": "Last time ran:",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
|
"loading": "Loading",
|
||||||
"local_archives": "Local archives",
|
"local_archives": "Local archives",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
@ -590,6 +595,7 @@
|
||||||
"protocol": "Protocol",
|
"protocol": "Protocol",
|
||||||
"purge_user_data_checkbox": "Purge {name}'s data? (This will remove the content of its home and mail directories.)",
|
"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!",
|
"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",
|
"readme": "Readme",
|
||||||
"rerun_diagnosis": "Rerun diagnosis",
|
"rerun_diagnosis": "Rerun diagnosis",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
// If a new locale or a new date-fns locale is added, add it to the supported
|
// If a new locale or a new date-fns locale is added, add it to the supported
|
||||||
// locales list in `app/vue.config.js`
|
// locales list in `app/vue.config.js`
|
||||||
|
|
||||||
export default {
|
const supportedLocales = {
|
||||||
ar: {
|
ar: {
|
||||||
name: 'عربي',
|
name: 'عربي',
|
||||||
},
|
},
|
||||||
|
@ -137,4 +137,21 @@ export default {
|
||||||
name: '简化字',
|
name: '简化字',
|
||||||
dateFnsLocale: 'zh-CN',
|
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.
|
// 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.
|
// Others will be chunked so they can be lazy loaded:
|
||||||
// Webpack chunk syntax is:
|
|
||||||
// `() => import('@/views/:ViewComponent.vue')`
|
// `() => import('@/views/:ViewComponent.vue')`
|
||||||
|
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
import HomeView from '@/views/HomeView.vue'
|
import HomeView from '@/views/HomeView.vue'
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
import ToolList from '@/views/tool/ToolList.vue'
|
import ToolList from '@/views/tool/ToolList.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
name: 'home',
|
name: 'home',
|
||||||
path: '/',
|
path: '/',
|
||||||
|
@ -55,6 +51,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'users' },
|
args: { trad: 'users' },
|
||||||
breadcrumb: ['user-list'],
|
breadcrumb: ['user-list'],
|
||||||
|
skeleton: 'ListGroupSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -64,6 +61,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'users_new' },
|
args: { trad: 'users_new' },
|
||||||
breadcrumb: ['user-list', 'user-create'],
|
breadcrumb: ['user-list', 'user-create'],
|
||||||
|
skeleton: 'CardFormSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -84,6 +82,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { param: 'name' },
|
args: { param: 'name' },
|
||||||
breadcrumb: ['user-list', 'user-info'],
|
breadcrumb: ['user-list', 'user-info'],
|
||||||
|
skeleton: 'CardInfoSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -94,6 +93,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { param: 'name', trad: 'user_username_edit' },
|
args: { param: 'name', trad: 'user_username_edit' },
|
||||||
breadcrumb: ['user-list', 'user-info', 'user-edit'],
|
breadcrumb: ['user-list', 'user-info', 'user-edit'],
|
||||||
|
skeleton: 'CardFormSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -107,6 +107,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'groups_and_permissions' },
|
args: { trad: 'groups_and_permissions' },
|
||||||
breadcrumb: ['user-list', 'group-list'],
|
breadcrumb: ['user-list', 'group-list'],
|
||||||
|
skeleton: 'CardFormSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -129,6 +130,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'domains' },
|
args: { trad: 'domains' },
|
||||||
breadcrumb: ['domain-list'],
|
breadcrumb: ['domain-list'],
|
||||||
|
skeleton: 'ListGroupSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -138,26 +140,21 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'domain_add' },
|
args: { trad: 'domain_add' },
|
||||||
breadcrumb: ['domain-list', 'domain-add'],
|
breadcrumb: ['domain-list', 'domain-add'],
|
||||||
|
skeleton: 'CardFormSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/domains/:name',
|
|
||||||
component: () => import('@/views/domain/DomainInfo.vue'),
|
|
||||||
props: true,
|
|
||||||
children: [
|
|
||||||
{
|
{
|
||||||
name: 'domain-info',
|
name: 'domain-info',
|
||||||
path: ':tabId?',
|
path: '/domains/:name/:tabId?',
|
||||||
component: () => import('@/components/ConfigPanel.vue'),
|
component: () => import('@/views/domain/DomainInfo.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
|
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
|
||||||
args: { param: 'name' },
|
args: { param: 'name' },
|
||||||
breadcrumb: ['domain-list', 'domain-info'],
|
breadcrumb: ['domain-list', 'domain-info'],
|
||||||
|
skeleton: 'CardListSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ───────╮
|
/* ───────╮
|
||||||
│ APPS │
|
│ APPS │
|
||||||
|
@ -169,6 +166,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'applications' },
|
args: { trad: 'applications' },
|
||||||
breadcrumb: ['app-list'],
|
breadcrumb: ['app-list'],
|
||||||
|
skeleton: 'ListGroupSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -179,6 +177,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'catalog' },
|
args: { trad: 'catalog' },
|
||||||
breadcrumb: ['app-list', 'app-catalog'],
|
breadcrumb: ['app-list', 'app-catalog'],
|
||||||
|
skeleton: 'AppCatalogSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -189,6 +188,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'install_name', param: 'id' },
|
args: { trad: 'install_name', param: 'id' },
|
||||||
breadcrumb: ['app-list', 'app-catalog', 'app-install'],
|
breadcrumb: ['app-list', 'app-catalog', 'app-install'],
|
||||||
|
skeleton: ['CardInfoSkeleton', { is: 'CardFormSkeleton', cols: null }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -199,26 +199,21 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'install_name', param: 'id' },
|
args: { trad: 'install_name', param: 'id' },
|
||||||
breadcrumb: ['app-list', 'app-catalog', 'app-install-custom'],
|
breadcrumb: ['app-list', 'app-catalog', 'app-install-custom'],
|
||||||
|
skeleton: ['CardInfoSkeleton', { is: 'CardFormSkeleton', cols: null }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/apps/:id',
|
|
||||||
component: () => import('@/views/app/AppInfo.vue'),
|
|
||||||
props: true,
|
|
||||||
children: [
|
|
||||||
{
|
{
|
||||||
name: 'app-info',
|
name: 'app-info',
|
||||||
path: ':tabId?',
|
path: '/apps/:id/:tabId?',
|
||||||
component: () => import('@/components/ConfigPanel.vue'),
|
component: () => import('@/views/app/AppInfo.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
routerParams: ['id'], // Override router key params to avoid view recreation at tab change.
|
routerParams: ['id'], // Override router key params to avoid view recreation at tab change.
|
||||||
args: { param: 'id' },
|
args: { param: 'id' },
|
||||||
breadcrumb: ['app-list', 'app-info'],
|
breadcrumb: ['app-list', 'app-info'],
|
||||||
|
skeleton: [{ is: 'CardInfoSkeleton', itemCount: 8 }, 'CardFormSkeleton'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ────────────────╮
|
/* ────────────────╮
|
||||||
│ SYSTEM UPDATE │
|
│ SYSTEM UPDATE │
|
||||||
|
@ -230,6 +225,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'system_update' },
|
args: { trad: 'system_update' },
|
||||||
breadcrumb: ['update'],
|
breadcrumb: ['update'],
|
||||||
|
skeleton: 'CardListSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -243,6 +239,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'services' },
|
args: { trad: 'services' },
|
||||||
breadcrumb: ['tool-list', 'service-list'],
|
breadcrumb: ['tool-list', 'service-list'],
|
||||||
|
skeleton: { is: 'ListGroupSkeleton', button: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -253,6 +250,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { param: 'name' },
|
args: { param: 'name' },
|
||||||
breadcrumb: ['tool-list', 'service-list', 'service-info'],
|
breadcrumb: ['tool-list', 'service-list', 'service-info'],
|
||||||
|
skeleton: ['CardInfoSkeleton', 'CardInfoSkeleton'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -275,16 +273,18 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'logs' },
|
args: { trad: 'logs' },
|
||||||
breadcrumb: ['tool-list', 'tool-logs'],
|
breadcrumb: ['tool-list', 'tool-logs'],
|
||||||
|
skeleton: { is: 'CardListSkeleton', search: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tool-log',
|
name: 'tool-log',
|
||||||
path: '/tools/logs/:name',
|
path: '/tools/logs/:name/:n?',
|
||||||
component: () => import('@/views/tool/ToolLog.vue'),
|
component: () => import('@/views/tool/ToolLog.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
args: { param: 'name' },
|
args: { param: 'name' },
|
||||||
breadcrumb: ['tool-list', 'tool-logs', 'tool-log'],
|
breadcrumb: ['tool-list', 'tool-logs', 'tool-log'],
|
||||||
|
skeleton: ['CardInfoSkeleton', 'CardInfoSkeleton'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -294,6 +294,10 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'migrations' },
|
args: { trad: 'migrations' },
|
||||||
breadcrumb: ['tool-list', 'tool-migrations'],
|
breadcrumb: ['tool-list', 'tool-migrations'],
|
||||||
|
skeleton: [
|
||||||
|
{ is: 'CardListSkeleton', itemCount: 3 },
|
||||||
|
{ is: 'CardListSkeleton', itemCount: 3 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -303,6 +307,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'firewall' },
|
args: { trad: 'firewall' },
|
||||||
breadcrumb: ['tool-list', 'tool-firewall'],
|
breadcrumb: ['tool-list', 'tool-firewall'],
|
||||||
|
skeleton: 'CardFormSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -314,23 +319,18 @@ const routes = [
|
||||||
breadcrumb: ['tool-list', 'tool-webadmin'],
|
breadcrumb: ['tool-list', 'tool-webadmin'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/tools/settings',
|
|
||||||
component: () => import('@/views/tool/ToolSettings.vue'),
|
|
||||||
children: [
|
|
||||||
{
|
{
|
||||||
name: 'tool-settings',
|
name: 'tool-settings',
|
||||||
path: ':tabId?',
|
path: '/tools/settings/:tabId?',
|
||||||
component: () => import('@/components/ConfigPanel.vue'),
|
component: () => import('@/views/tool/ToolSettings.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
routerParams: [],
|
routerParams: [],
|
||||||
args: { trad: 'tools_yunohost_settings' },
|
args: { trad: 'tools_yunohost_settings' },
|
||||||
breadcrumb: ['tool-list', 'tool-settings'],
|
breadcrumb: ['tool-list', 'tool-settings'],
|
||||||
|
skeleton: 'CardFormSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'tool-power',
|
name: 'tool-power',
|
||||||
path: '/tools/power',
|
path: '/tools/power',
|
||||||
|
@ -351,6 +351,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'diagnosis' },
|
args: { trad: 'diagnosis' },
|
||||||
breadcrumb: ['diagnosis'],
|
breadcrumb: ['diagnosis'],
|
||||||
|
skeleton: ['CardListSkeleton', 'CardListSkeleton', 'CardListSkeleton'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -374,6 +375,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { param: 'id' },
|
args: { param: 'id' },
|
||||||
breadcrumb: ['backup', 'backup-list'],
|
breadcrumb: ['backup', 'backup-list'],
|
||||||
|
skeleton: 'ListGroupSkeleton',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -384,6 +386,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { param: 'name' },
|
args: { param: 'name' },
|
||||||
breadcrumb: ['backup', 'backup-list', 'backup-info'],
|
breadcrumb: ['backup', 'backup-list', 'backup-info'],
|
||||||
|
skeleton: [{ is: 'CardInfoSkeleton', itemCount: 4 }, 'CardListSkeleton'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -394,6 +397,7 @@ const routes = [
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'backup_create' },
|
args: { trad: 'backup_create' },
|
||||||
breadcrumb: ['backup', 'backup-list', '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