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,138 +1,68 @@
<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>>
onSubmit: (
fn: (onError: (err: APIError) => void) => void,
) => (e: SubmitEvent) => void
}
| undefined
>()
const { loading } = useInitialQueries(
[
{ uri: 'apps/catalog?full&with_categories&with_antifeatures' },
{ uri: `apps/manifest?app=${props.id}&with_screenshot` },
],
{ onQueriesResponse },
)
// FIXME
const app = ref(undefined)
const name = ref(undefined)
const force = ref(false)
function appLinksIcons(linkType) {
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( const antifeaturesList = Object.fromEntries(
catalog.antifeatures.map((af) => [af.id, af]), catalog.antifeatures.map((af) => [af.id, af]),
) )
const { id, name, version, screenshot, requirements } = manifest
const { id, name, version, requirements } = _app const quality = formatAppQuality(manifest.quality)
const { const preInstall = formatI18nField(manifest.notifications.PRE_INSTALL?.main)
ldap, const antifeatures = manifest.antifeatures?.length
sso, ? manifest.antifeatures.map((af) => antifeaturesList[af])
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 : null
const hasDanger = quality.variant === 'danger' || !requirements.ram.pass const hasDanger = quality.variant === 'danger' || !requirements.ram.pass
const hasSupport = Object.keys(requirements).every((key) => { const hasSupport = getKeys(requirements).every((key) => {
// ram support is non-blocking requirement and handled on its own. // ram support is non-blocking requirement and handled on its own.
return key === 'ram' || requirements[key].pass return key === 'ram' || requirements[key].pass
}) })
const app_ = { const app = {
id, id,
name, name,
alternativeTo: alternativeTo: joinOrNull(manifest.potential_alternative_to),
_app.potential_alternative_to && _app.potential_alternative_to.length description: formatI18nField(
? _app.potential_alternative_to.join(t('words.separator')) manifest.doc.DESCRIPTION || manifest.description,
: null, ),
description: formatI18nField(_app.doc.DESCRIPTION || _app.description), screenshot,
screenshot: _app.screenshot, demo: manifest.upstream.demo,
demo: _app.upstream.demo,
version, version,
license: _app.upstream.license, license: manifest.upstream.license,
integration: integration: formatAppIntegration(
_app.packaging_format >= 2 manifest.integration,
? { manifest.packaging_format,
archs: Array.isArray(archs) ),
? archs.join(t('words.separator')) links: formatAppLinks(manifest),
: 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, preInstall,
antifeatures, antifeatures,
quality, quality,
@ -143,37 +73,28 @@ function onQueriesResponse(catalog: any, _app: any) {
canInstall: hasSupport && !hasDanger, canInstall: hasSupport && !hasDanger,
} }
const { form, fields } = formatOptions([
// FIXME yunohost should add the label field by default // FIXME yunohost should add the label field by default
_app.install.unshift({ {
type: 'string', type: 'string',
id: 'label', id: 'label',
ask: t('label_for_manifestname', { name }), ask: t('label_for_manifestname', { name }),
default: name, default: name,
help: t('label_for_manifestname_help'), help: t('label_for_manifestname_help'),
optional: false, optional: false,
},
...manifest.install,
])
return [app, form, fields] as const
}) })
const { form, fields } = formatOptions(_app.install)
const { v, onSubmit } = useForm(form, fields) const { v, onSubmit } = useForm(form, fields)
formData.value = { form, fields, v, onSubmit } const force = ref(false)
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!
const performInstall = onSubmit(async (onError) => {
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',