mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
refactor: rework async AppInstall
This commit is contained in:
parent
a5714e56fc
commit
301fd4d36c
3 changed files with 130 additions and 228 deletions
|
@ -1,5 +1,6 @@
|
||||||
import type { Obj, Translation } from '@/types/commons'
|
import type { Obj, Translation } from '@/types/commons'
|
||||||
import type { Permission } from '@/types/core/data'
|
import type { Permission } from '@/types/core/data'
|
||||||
|
import type { AnyOption } from '@/types/core/options'
|
||||||
|
|
||||||
// APPS
|
// APPS
|
||||||
|
|
||||||
|
@ -32,6 +33,36 @@ export type AppMinManifest = {
|
||||||
}
|
}
|
||||||
upstream: AppUpstream
|
upstream: AppUpstream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppManifest = AppMinManifest & {
|
||||||
|
upstream: AppUpstream
|
||||||
|
install: AnyOption[]
|
||||||
|
doc: {
|
||||||
|
DESCRIPTION?: Translation
|
||||||
|
ADMIN?: Translation
|
||||||
|
} & Obj<Translation>
|
||||||
|
notifications: {
|
||||||
|
PRE_INSTALL: Obj<Translation> | null
|
||||||
|
POST_INSTALL: Obj<Translation> | null
|
||||||
|
PRE_UPGRADE: Obj<Translation> | null
|
||||||
|
POST_UPGRADE: Obj<Translation> | null
|
||||||
|
}
|
||||||
|
requirements: Record<
|
||||||
|
'required_yunohost_version' | 'arch' | 'install' | 'disk' | 'ram',
|
||||||
|
{
|
||||||
|
pass: boolean
|
||||||
|
values: { current: string; required: string }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
resources: Obj
|
||||||
|
remote: { type: string; url: string; branch: string; revision: string }
|
||||||
|
lastUpdate: number
|
||||||
|
quality: { level: AppLevel; state: AppState }
|
||||||
|
antifeatures: string[]
|
||||||
|
potential_alternative_to: string[]
|
||||||
|
screenshot: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type CatalogApp = {
|
type CatalogApp = {
|
||||||
added_in_catalog: number
|
added_in_catalog: number
|
||||||
antifeatures: string[]
|
antifeatures: string[]
|
||||||
|
|
|
@ -1,179 +1,100 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, shallowRef, type Ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import api, { objectToParams } from '@/api'
|
import api, { objectToParams } from '@/api'
|
||||||
import { type APIError } from '@/api/errors'
|
|
||||||
import { formatOptions } from '@/composables/configPanels'
|
import { formatOptions } from '@/composables/configPanels'
|
||||||
import { useForm, type FormValidation } from '@/composables/form'
|
import { useForm } from '@/composables/form'
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
import { useInitialQueries } from '@/composables/useInitialQueries'
|
import { getKeys, joinOrNull } from '@/helpers/commons'
|
||||||
import { formatForm, formatI18nField } from '@/helpers/yunohostArguments'
|
import { formatForm, formatI18nField } from '@/helpers/yunohostArguments'
|
||||||
import type { Obj } from '@/types/commons'
|
import type { Obj } from '@/types/commons'
|
||||||
import type { FormFieldDict } from '@/types/form'
|
import type { AppManifest, Catalog } from '@/types/core/api'
|
||||||
|
import {
|
||||||
|
formatAppIntegration,
|
||||||
|
formatAppLinks,
|
||||||
|
formatAppNotifs,
|
||||||
|
formatAppQuality,
|
||||||
|
} from '@/views/app/appData'
|
||||||
|
import AppIntegrationAndLinks from './_AppIntegrationAndLinks.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: string
|
id: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const modalConfirm = useAutoModal()
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
// TODO: handling async form is a mess, make a composable or something?
|
const [app, form, fields] = await api
|
||||||
const formData = shallowRef<
|
.fetchAll<
|
||||||
| {
|
[Catalog, AppManifest]
|
||||||
form: Ref<Obj>
|
>([{ uri: 'apps/catalog?full&with_categories&with_antifeatures' }, { uri: `apps/manifest?app=${props.id}&with_screenshot` }])
|
||||||
fields: FormFieldDict<Obj>
|
.then(([catalog, manifest]) => {
|
||||||
v: Ref<FormValidation<Obj>>
|
const antifeaturesList = Object.fromEntries(
|
||||||
onSubmit: (
|
catalog.antifeatures.map((af) => [af.id, af]),
|
||||||
fn: (onError: (err: APIError) => void) => void,
|
)
|
||||||
) => (e: SubmitEvent) => void
|
const { id, name, version, screenshot, requirements } = manifest
|
||||||
|
const quality = formatAppQuality(manifest.quality)
|
||||||
|
const preInstall = formatI18nField(manifest.notifications.PRE_INSTALL?.main)
|
||||||
|
const antifeatures = manifest.antifeatures?.length
|
||||||
|
? manifest.antifeatures.map((af) => antifeaturesList[af])
|
||||||
|
: null
|
||||||
|
const hasDanger = quality.variant === 'danger' || !requirements.ram.pass
|
||||||
|
const hasSupport = getKeys(requirements).every((key) => {
|
||||||
|
// ram support is non-blocking requirement and handled on its own.
|
||||||
|
return key === 'ram' || requirements[key].pass
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
alternativeTo: joinOrNull(manifest.potential_alternative_to),
|
||||||
|
description: formatI18nField(
|
||||||
|
manifest.doc.DESCRIPTION || manifest.description,
|
||||||
|
),
|
||||||
|
screenshot,
|
||||||
|
demo: manifest.upstream.demo,
|
||||||
|
version,
|
||||||
|
license: manifest.upstream.license,
|
||||||
|
integration: formatAppIntegration(
|
||||||
|
manifest.integration,
|
||||||
|
manifest.packaging_format,
|
||||||
|
),
|
||||||
|
links: formatAppLinks(manifest),
|
||||||
|
preInstall,
|
||||||
|
antifeatures,
|
||||||
|
quality,
|
||||||
|
requirements,
|
||||||
|
hasWarning: !!preInstall || antifeatures || quality.variant === 'warning',
|
||||||
|
hasDanger,
|
||||||
|
hasSupport,
|
||||||
|
canInstall: hasSupport && !hasDanger,
|
||||||
}
|
}
|
||||||
| undefined
|
|
||||||
>()
|
|
||||||
|
|
||||||
const { loading } = useInitialQueries(
|
const { form, fields } = formatOptions([
|
||||||
[
|
// FIXME yunohost should add the label field by default
|
||||||
{ uri: 'apps/catalog?full&with_categories&with_antifeatures' },
|
{
|
||||||
{ uri: `apps/manifest?app=${props.id}&with_screenshot` },
|
type: 'string',
|
||||||
],
|
id: 'label',
|
||||||
{ onQueriesResponse },
|
ask: t('label_for_manifestname', { name }),
|
||||||
)
|
default: name,
|
||||||
|
help: t('label_for_manifestname_help'),
|
||||||
|
optional: false,
|
||||||
|
},
|
||||||
|
...manifest.install,
|
||||||
|
])
|
||||||
|
return [app, form, fields] as const
|
||||||
|
})
|
||||||
|
|
||||||
// FIXME
|
const { v, onSubmit } = useForm(form, fields)
|
||||||
const app = ref(undefined)
|
|
||||||
const name = ref(undefined)
|
|
||||||
const force = ref(false)
|
const force = ref(false)
|
||||||
|
|
||||||
function appLinksIcons(linkType) {
|
const performInstall = onSubmit(async (onError) => {
|
||||||
const linksIcons = {
|
|
||||||
license: 'institution',
|
|
||||||
website: 'globe',
|
|
||||||
admindoc: 'book',
|
|
||||||
userdoc: 'book',
|
|
||||||
code: 'code',
|
|
||||||
package: 'code',
|
|
||||||
package_license: 'institution',
|
|
||||||
forum: 'comments',
|
|
||||||
}
|
|
||||||
return linksIcons[linkType]
|
|
||||||
}
|
|
||||||
|
|
||||||
function onQueriesResponse(catalog: any, _app: any) {
|
|
||||||
const antifeaturesList = Object.fromEntries(
|
|
||||||
catalog.antifeatures.map((af) => [af.id, af]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { id, name, version, requirements } = _app
|
|
||||||
const {
|
|
||||||
ldap,
|
|
||||||
sso,
|
|
||||||
multi_instance,
|
|
||||||
ram,
|
|
||||||
disk,
|
|
||||||
architectures: archs,
|
|
||||||
} = _app.integration
|
|
||||||
|
|
||||||
const quality = { state: _app.quality.state, variant: 'danger' }
|
|
||||||
if (quality.state === 'working') {
|
|
||||||
if (_app.quality.level <= 0) {
|
|
||||||
quality.state = 'broken'
|
|
||||||
} else if (_app.quality.level <= 4) {
|
|
||||||
quality.state = 'lowquality'
|
|
||||||
quality.variant = 'warning'
|
|
||||||
} else {
|
|
||||||
quality.variant = 'success'
|
|
||||||
quality.state = _app.quality.level >= 8 ? 'highquality' : 'goodquality'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const preInstall = formatI18nField(_app.notifications.PRE_INSTALL.main)
|
|
||||||
const antifeatures = _app.antifeatures?.length
|
|
||||||
? _app.antifeatures.map((af) => antifeaturesList[af])
|
|
||||||
: null
|
|
||||||
|
|
||||||
const hasDanger = quality.variant === 'danger' || !requirements.ram.pass
|
|
||||||
const hasSupport = Object.keys(requirements).every((key) => {
|
|
||||||
// ram support is non-blocking requirement and handled on its own.
|
|
||||||
return key === 'ram' || requirements[key].pass
|
|
||||||
})
|
|
||||||
|
|
||||||
const app_ = {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
alternativeTo:
|
|
||||||
_app.potential_alternative_to && _app.potential_alternative_to.length
|
|
||||||
? _app.potential_alternative_to.join(t('words.separator'))
|
|
||||||
: null,
|
|
||||||
description: formatI18nField(_app.doc.DESCRIPTION || _app.description),
|
|
||||||
screenshot: _app.screenshot,
|
|
||||||
demo: _app.upstream.demo,
|
|
||||||
version,
|
|
||||||
license: _app.upstream.license,
|
|
||||||
integration:
|
|
||||||
_app.packaging_format >= 2
|
|
||||||
? {
|
|
||||||
archs: Array.isArray(archs)
|
|
||||||
? archs.join(t('words.separator'))
|
|
||||||
: archs,
|
|
||||||
ldap: ldap === 'not_relevant' ? null : ldap,
|
|
||||||
sso: sso === 'not_relevant' ? null : sso,
|
|
||||||
multi_instance,
|
|
||||||
resources: { ram: ram.runtime, disk },
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
links: [
|
|
||||||
['license', `https://spdx.org/licenses/${_app.upstream.license}`],
|
|
||||||
...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
|
|
||||||
return [key, _app.upstream[key]]
|
|
||||||
}),
|
|
||||||
['package', _app.remote.url],
|
|
||||||
['package_license', _app.remote.url + '/blob/master/LICENSE'],
|
|
||||||
['forum', `https://forum.yunohost.org/tag/${id}`],
|
|
||||||
].filter(([key, val]) => !!val),
|
|
||||||
preInstall,
|
|
||||||
antifeatures,
|
|
||||||
quality,
|
|
||||||
requirements,
|
|
||||||
hasWarning: !!preInstall || antifeatures || quality.variant === 'warning',
|
|
||||||
hasDanger,
|
|
||||||
hasSupport,
|
|
||||||
canInstall: hasSupport && !hasDanger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME yunohost should add the label field by default
|
|
||||||
_app.install.unshift({
|
|
||||||
type: 'string',
|
|
||||||
id: 'label',
|
|
||||||
ask: t('label_for_manifestname', { name }),
|
|
||||||
default: name,
|
|
||||||
help: t('label_for_manifestname_help'),
|
|
||||||
optional: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { form, fields } = formatOptions(_app.install)
|
|
||||||
const { v, onSubmit } = useForm(form, fields)
|
|
||||||
formData.value = { form, fields, v, onSubmit }
|
|
||||||
|
|
||||||
app.value = app_
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAppNotifs(notifs) {
|
|
||||||
return Object.keys(notifs).reduce((acc, key) => {
|
|
||||||
return acc + '\n\n' + notifs[key]
|
|
||||||
}, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performInstall(onError: (err: APIError) => void) {
|
|
||||||
const { form } = formData.value!
|
|
||||||
|
|
||||||
if ('path' in form.value && form.value.path === '/') {
|
if ('path' in form.value && form.value.path === '/') {
|
||||||
const confirmed = await modalConfirm(
|
const confirmed = await modalConfirm(
|
||||||
t('confirm_install_domain_root', {
|
t('confirm_install_domain_root', { domain: form.value.domain }),
|
||||||
domain: form.value.domain,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
}
|
}
|
||||||
|
@ -189,10 +110,10 @@ async function performInstall(onError: (err: APIError) => void) {
|
||||||
.post({
|
.post({
|
||||||
uri: 'apps',
|
uri: 'apps',
|
||||||
data,
|
data,
|
||||||
humanKey: { key: 'apps.install', name: app.value.name },
|
humanKey: { key: 'apps.install', name: app.name },
|
||||||
})
|
})
|
||||||
.then(async ({ notifications }) => {
|
.then(async (response: { notifications: Obj<string> }) => {
|
||||||
const postInstall = formatAppNotifs(notifications)
|
const postInstall = formatAppNotifs(response.notifications)
|
||||||
if (postInstall) {
|
if (postInstall) {
|
||||||
const message =
|
const message =
|
||||||
t('app.install.notifs.post.alert') + '\n\n' + postInstall
|
t('app.install.notifs.post.alert') + '\n\n' + postInstall
|
||||||
|
@ -200,7 +121,7 @@ async function performInstall(onError: (err: APIError) => void) {
|
||||||
message,
|
message,
|
||||||
{
|
{
|
||||||
title: t('app.install.notifs.post.title', {
|
title: t('app.install.notifs.post.title', {
|
||||||
name: app.value.name,
|
name: app.name,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{ markdown: true, cancelable: false },
|
{ markdown: true, cancelable: false },
|
||||||
|
@ -209,11 +130,11 @@ async function performInstall(onError: (err: APIError) => void) {
|
||||||
router.push({ name: 'app-list' })
|
router.push({ name: 'app-list' })
|
||||||
})
|
})
|
||||||
.catch(onError)
|
.catch(onError)
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase :loading="loading">
|
<div>
|
||||||
<template v-if="app">
|
<template v-if="app">
|
||||||
<section class="border rounded p-3 mb-4">
|
<section class="border rounded p-3 mb-4">
|
||||||
<div class="d-md-flex align-items-center mb-4">
|
<div class="d-md-flex align-items-center mb-4">
|
||||||
|
@ -252,64 +173,10 @@ async function performInstall(onError: (err: APIError) => void) {
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<YCard
|
<AppIntegrationAndLinks
|
||||||
v-if="app.integration"
|
:integration="app.integration"
|
||||||
id="app-integration"
|
:links="app.links"
|
||||||
:title="$t('app.integration.title')"
|
/>
|
||||||
collapsable
|
|
||||||
collapsed
|
|
||||||
no-body
|
|
||||||
>
|
|
||||||
<BListGroup flush>
|
|
||||||
<YListGroupItem variant="info">
|
|
||||||
{{ $t('app.integration.archs') }} {{ app.integration.archs }}
|
|
||||||
</YListGroupItem>
|
|
||||||
<YListGroupItem
|
|
||||||
v-if="app.integration.ldap"
|
|
||||||
:variant="app.integration.ldap === true ? 'success' : 'warning'"
|
|
||||||
>
|
|
||||||
{{ $t(`app.integration.ldap.${app.integration.ldap}`) }}
|
|
||||||
</YListGroupItem>
|
|
||||||
<YListGroupItem
|
|
||||||
v-if="app.integration.sso"
|
|
||||||
:variant="app.integration.sso === true ? 'success' : 'warning'"
|
|
||||||
>
|
|
||||||
{{ $t(`app.integration.sso.${app.integration.sso}`) }}
|
|
||||||
</YListGroupItem>
|
|
||||||
<YListGroupItem variant="info">
|
|
||||||
{{
|
|
||||||
$t(
|
|
||||||
`app.integration.multi_instance.${app.integration.multi_instance}`,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</YListGroupItem>
|
|
||||||
<YListGroupItem variant="info">
|
|
||||||
{{ $t('app.integration.resources', app.integration.resources) }}
|
|
||||||
</YListGroupItem>
|
|
||||||
</BListGroup>
|
|
||||||
</YCard>
|
|
||||||
|
|
||||||
<YCard
|
|
||||||
id="app-links"
|
|
||||||
icon="link"
|
|
||||||
:title="$t('app.links.title')"
|
|
||||||
collapsable
|
|
||||||
collapsed
|
|
||||||
no-body
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<h2><YIcon iname="link" /> {{ $t('app.links.title') }}</h2>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<BListGroup flush>
|
|
||||||
<YListGroupItem v-for="[key, link] in app.links" :key="key" no-status>
|
|
||||||
<BLink :href="link" target="_blank">
|
|
||||||
<YIcon :iname="appLinksIcons(key)" class="me-1" />
|
|
||||||
{{ $t('app.links.' + key) }}
|
|
||||||
</BLink>
|
|
||||||
</YListGroupItem>
|
|
||||||
</BListGroup>
|
|
||||||
</YCard>
|
|
||||||
|
|
||||||
<YAlert v-if="app.hasWarning" variant="warning" class="my-4">
|
<YAlert v-if="app.hasWarning" variant="warning" class="my-4">
|
||||||
<h2>{{ $t('app.install.notifs.pre.warning') }}</h2>
|
<h2>{{ $t('app.install.notifs.pre.warning') }}</h2>
|
||||||
|
@ -385,28 +252,24 @@ async function performInstall(onError: (err: APIError) => void) {
|
||||||
|
|
||||||
<!-- INSTALL FORM -->
|
<!-- INSTALL FORM -->
|
||||||
<CardForm
|
<CardForm
|
||||||
v-if="formData && (app.canInstall || force)"
|
v-if="app.canInstall || force"
|
||||||
v-model="formData.form.value"
|
v-model="form"
|
||||||
:fields="formData.fields"
|
:fields="fields"
|
||||||
:title="$t('app_install_parameters')"
|
:title="$t('app_install_parameters')"
|
||||||
icon="cog"
|
icon="cog"
|
||||||
:submit-text="$t('install')"
|
:submit-text="$t('install')"
|
||||||
:validations="formData.v.value"
|
:validations="v"
|
||||||
@submit="formData.onSubmit(performInstall)($event)"
|
@submit="performInstall"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- FIXME hum not handled, is it still a thing? -->
|
||||||
<!-- In case of a custom url with no manifest found -->
|
<!-- In case of a custom url with no manifest found -->
|
||||||
<BAlert :modelValue="app === null" variant="warning">
|
<BAlert :modelValue="app === null" variant="warning">
|
||||||
<YIcon iname="exclamation-triangle" />
|
<YIcon iname="exclamation-triangle" />
|
||||||
{{ $t('app_install_custom_no_manifest') }}
|
{{ $t('app_install_custom_no_manifest') }}
|
||||||
</BAlert>
|
</BAlert>
|
||||||
|
</div>
|
||||||
<template #skeleton>
|
|
||||||
<CardInfoSkeleton />
|
|
||||||
<CardFormSkeleton :cols="null" />
|
|
||||||
</template>
|
|
||||||
</ViewBase>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { joinOrNull } from '@/helpers/commons'
|
import { getKeys, joinOrNull } from '@/helpers/commons'
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
import type { AppLevel, AppManifest, AppState } from '@/types/core/api'
|
import type { AppLevel, AppManifest, AppState } from '@/types/core/api'
|
||||||
|
|
||||||
|
export function formatAppNotifs(notifs: Obj<string> | null): string {
|
||||||
|
if (!notifs) return ''
|
||||||
|
return getKeys(notifs).reduce((acc, key) => {
|
||||||
|
return acc + '\n\n' + notifs[key]
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAppQuality(app: { state: AppState; level: AppLevel }) {
|
export function formatAppQuality(app: { state: AppState; level: AppLevel }) {
|
||||||
const variants = {
|
const variants = {
|
||||||
working: 'success',
|
working: 'success',
|
||||||
|
|
Loading…
Reference in a new issue