refactor: rework async AppInstall

This commit is contained in:
axolotle 2024-08-13 00:14:52 +02:00
parent a5714e56fc
commit 301fd4d36c
3 changed files with 130 additions and 228 deletions

View file

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

View file

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

View file

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