Merge pull request #486 from YunoHost/enh-appv2modal

[enh] app v2 changes for AppCatalog, AppInstall, AppInfo and SystemUpdate (apps)
This commit is contained in:
Alexandre Aubin 2023-01-06 00:03:03 +01:00 committed by GitHub
commit 335d69168c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1219 additions and 403 deletions

View file

@ -0,0 +1,82 @@
<template>
<b-card
v-bind="$attrs"
no-body :class="_class"
>
<slot name="header" slot="header">
<h2>
<b-button v-b-toggle="id" :variant="variant" class="card-collapse-button">
{{ title }}
<icon class="ml-auto" iname="chevron-right" />
</b-button>
</h2>
</slot>
<b-collapse :id="id" :visible="visible" role="region">
<slot name="default" />
</b-collapse>
</b-card>
</template>
<script>
export default {
name: 'CollapseCard',
props: {
id: { type: String, required: true },
title: { type: String, required: true },
variant: { type: String, default: 'white' },
visible: { type: Boolean, default: false },
flush: { type: Boolean, default: false }
},
computed: {
_class () {
const baseClass = 'card-collapse'
return [
baseClass,
{
[`${baseClass}-flush`]: this.flush,
[`${baseClass}-${this.variant}`]: this.variant
}
]
}
}
}
</script>
<style lang="scss" scoped>
.card-collapse {
.card-header {
padding: 0;
}
&-button {
display: flex;
width: 100%;
text-align: left;
padding-top: $spacer * .5;
padding-bottom: $spacer * .5;
border-radius: 0;
font: inherit
}
&-flush {
border-radius: 0;
border-left: 0;
border-right: 0;
}
& + & {
margin-top: 0;
border-top: 0;
}
@each $color, $value in $theme-colors {
&-#{$color} {
background-color: $value;
color: color-yiq($value);
}
}
}
</style>

View file

@ -0,0 +1,81 @@
<script>
// Implementation of the feed pattern
// https://www.w3.org/WAI/ARIA/apg/patterns/feed/
export default {
name: 'CardDeckFeed',
props: {
stacks: { type: Number, default: 21 }
},
data () {
return {
busy: false,
range: this.stacks,
childrenCount: this.$slots.default.length
}
},
methods: {
getTopParent (prev) {
return prev.parentElement === this.$refs.feed ? prev : this.getTopParent(prev.parentElement)
},
onScroll () {
const elem = this.$refs.feed
if (window.innerHeight > elem.clientHeight + elem.getBoundingClientRect().top - 200) {
this.busy = true
this.range = Math.min(this.range + this.stacks, this.childrenCount)
this.$nextTick().then(() => {
this.busy = false
})
}
},
onKeydown (e) {
if (['PageUp', 'PageDown'].includes(e.code)) {
e.preventDefault()
const key = e.code === 'PageUp' ? 'previous' : 'next'
const sibling = this.getTopParent(e.target)[`${key}ElementSibling`]
if (sibling) {
sibling.focus()
sibling.scrollIntoView({ block: 'center' })
}
}
// FIXME Add `Home` and `End` shorcuts
}
},
mounted () {
window.addEventListener('scroll', this.onScroll)
this.$refs.feed.addEventListener('keydown', this.onKeydown)
this.onScroll()
},
beforeUpdate () {
const slots = this.$slots.default
if (this.childrenCount !== slots.length) {
this.range = this.stacks
this.childrenCount = slots.length
}
},
render (h) {
return h(
'b-card-group',
{
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)
}
}
</script>

View file

@ -24,6 +24,12 @@ export default {
min-width: 3rem;
}
&.md {
width: 1.25rem;
font-size: 1.25rem;
min-width: 1.25rem;
}
&.fs-sm {
font-size: 1rem;
}

View file

@ -0,0 +1,35 @@
<template>
<component
v-bind="$attrs"
:is="alert ? 'b-alert' : 'div'"
:variant="alert ? variant : null"
:class="{ ['alert alert-' + variant]: !alert }"
class="yuno-alert d-flex flex-column flex-md-row align-items-center"
>
<icon :iname="_icon" class="mr-md-3 mb-md-0 mb-2 md" />
<div class="w-100">
<slot name="default" />
</div>
</component>
</template>
<script>
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
export default {
name: 'YunoAlert',
props: {
alert: { type: Boolean, default: false },
variant: { type: String, default: 'info' },
icon: { type: String, default: null }
},
computed: {
_icon () {
return DEFAULT_STATUS_ICON[this.variant]
}
}
}
</script>

View file

@ -262,8 +262,11 @@ export function formatYunoHostArgument (arg) {
]
// Default type management if no one is filled
if (arg.choices && arg.choices.length) {
arg.type = 'select'
}
if (arg.type === undefined) {
arg.type = arg.choices && arg.choices.length ? 'select' : 'string'
arg.type = 'string'
}
// Search the component bind to the type

View file

@ -54,10 +54,106 @@
"api_not_found": "Seems like the web-admin tried to query something that doesn't exist.",
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?",
"api_waiting": "Waiting for the server's response...",
"app": {
"installed_version": "Installed version: {version}",
"open_this_app": "Open this app",
"antifeatures": "This app has features you may not like:",
"doc": {
"about": {
"title": "About",
"description": "Description"
},
"admin": {
"title": "Admin doc"
},
"notifications": {
"dismiss": "Dismiss",
"title": "Notifications",
"post_upgrade": "Post-upgrade notes",
"post_install": "Post-install notes"
}
},
"info": {
"forum": "Search or ask the forum!",
"problem": "A problem with this app?"
},
"install": {
"license": "License: {license}",
"notifs": {
"post": {
"title": "Post-install notifications for '{name}'",
"alert": "It seems that the installation went well!\n Here is some notifications that the packager considers important to know.\nYou can read it again in the app info page."
},
"pre": {
"warning": "Things to know before installation",
"danger": "The installation of the application will most likely lead to issues",
"critical": "The application cannot be installed"
}
},
"problems": {
"arch": "This app can only be installed on specific architectures ({required}) but your server architecture is {current}.",
"broken": "This application is broken according to YunoHost's automatic tests and it is likely to break your system! You should probably NOT install it unless you know what you are doing.",
"thirdparty": "This application is not part of the official YunoHost catalog, installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing.",
"ignore": "I understand that this installation may break my system but i still want to try.",
"inprogress": "This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing.",
"install": "It is already installed and can't be installed more than once.",
"lowquality": "This application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available, or it does not respect the good practices.",
"ram": "This application requires {required} of RAM to install/upgrade but only {current} is available right now. Even if this app could run, its installation process requires a large amount of RAM so your server may freeze and fail miserably.",
"version": "This application requires YunoHost >= {required} but your current installed version is {current}, consider first upgrading YunoHost."
},
"try_demo": "Try the demo",
"version": "Current version: {version}"
},
"integration": {
"archs": "Supported architectures:",
"ldap": {
"false": "Does not use YunoHost accounts to login (LDAP)",
"true": "Use YunoHost accounts to login (LDAP)",
"?": "No information about LDAP integration"
},
"multi_instance": {
"false": "Can be installed only once",
"true": "Can be installed several times"
},
"resources": "Typical resource usage: {ram} RAM, {disk} disk",
"sso": {
"false": "Single sign-on is not available (SSO)",
"true": "Single sign-on is available (SSO)",
"?": "No information about SSO integration"
},
"title": "YunoHost integration"
},
"links": {
"admindoc": "Official Admin documentation",
"code": "Official code repository",
"forum": "Topics about this app on YunoHost's forum",
"package": "YunoHost package repository",
"title": "Links",
"userdoc": "Official User documentation",
"website": "Official Website",
"license": "License"
},
"potential_alternative_to": "Potential alternative to:",
"upgrade": {
"confirm": {
"apps": "Apps that will be upgraded",
"title": "Confirm app upgrades"
},
"continue": "Continue to next app",
"notifs": {
"pre": {
"alert": "You should check those notifications before upgrading, there might be important stuff to know.",
"title": "Be warned!"
},
"post": {
"alert": "It seems that the upgrade went well!\n Here is some notifications that the packager considers important to know about this upgrade.\nYou can read it again in the app info page.",
"title": "Post-upgrade notifications for '{name}'"
}
},
"stop": "Cancel next app upgrades"
}
},
"app_choose_category": "Choose a category",
"app_config_panel": "Config panel",
"app_config_panel_label": "Configure this app",
"app_config_panel_no_panel": "This application doesn't have any configuration available",
"app_info_access_desc": "Groups / users currently allowed to access this app:",
"app_info_change_url_disabled_tooltip": "This feature hasn't been implemented in this app yet",
"app_info_changeurl_desc": "Change the access URL of this application (domain and/or path).",
@ -118,6 +214,8 @@
"confirm_service_start": "Are you sure you want to start {name}?",
"confirm_service_stop": "Are you sure you want to stop {name}?",
"confirm_uninstall": "Are you sure you want to uninstall {name}?",
"confirm_update_system": "Are you sure you want to update all system packages?",
"confirm_upnp_enable": "Are you sure you want to enable UPnP?",
"confirm_update_apps": "Are you sure you want to update all applications?",
"confirm_update_specific_app": "Are you sure you want to update {app}?",
"confirm_update_system": "Are you sure you want to update all system packages?",
@ -292,6 +390,7 @@
"change_url": "Change access URL of '{name}'",
"install": "Install app '{name}'",
"set_default": "Redirect '{domain}' domain root to '{name}'",
"dismiss_notification": "Dismiss notification for '{name}'",
"uninstall": "Uninstall app '{name}'",
"update_config": "Update panel '{id}' of app '{name}' configuration"
},

View file

@ -39,6 +39,19 @@ Vue.prototype.$askConfirmation = function (message, 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 requireComponent = require.context('@/components/globals', true, /\.(js|vue)$/i)

View file

@ -175,6 +175,7 @@ const routes = [
name: 'app-catalog',
path: '/apps/catalog',
component: () => import(/* webpackChunkName: "views/apps/catalog" */ '@/views/app/AppCatalog'),
props: route => route.query,
meta: {
args: { trad: 'catalog' },
breadcrumb: ['app-list', 'app-catalog']
@ -201,30 +202,19 @@ const routes = [
}
},
{
name: 'app-info',
path: '/apps/:id',
component: () => import(/* webpackChunkName: "views/apps/info" */ '@/views/app/AppInfo'),
props: true,
meta: {
args: { param: 'id' },
breadcrumb: ['app-list', 'app-info']
}
},
{
// no need for name here, only children are visited
path: '/apps/:id/config-panel',
component: () => import(/* webpackChunkName: "views/apps/config" */ '@/views/app/AppConfigPanel'),
props: true,
children: [
{
name: 'app-config-panel',
name: 'app-info',
path: ':tabId?',
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
props: true,
meta: {
routerParams: ['id'],
args: { trad: 'app_config_panel' },
breadcrumb: ['app-list', 'app-info', 'app-config-panel']
routerParams: ['id'], // Override router key params to avoid view recreation at tab change.
args: { param: 'id' },
breadcrumb: ['app-list', 'app-info']
}
}
]

View file

@ -216,7 +216,7 @@ body {
margin-top: 0;
}
.card, .list-group-item {
.card-header, .list-group-item {
h1, h2, h3, h4, h5, h6 {
margin: 0;
}

View file

@ -12,10 +12,10 @@
</b-input-group-prepend>
<b-form-input
id="search-input" :placeholder="$t('search.for', { items: $tc('items.apps', 2) })"
v-model="search" @input="setCategory"
:value="search" @input="updateQuery('search', $event)"
/>
<b-input-group-append>
<b-select v-model="quality" :options="qualityOptions" @change="setCategory" />
<b-select :value="quality" :options="qualityOptions" @change="updateQuery('quality', $event)" />
</b-input-group-append>
</b-input-group>
@ -24,9 +24,9 @@
<b-input-group-prepend is-text>
<icon iname="filter" />
</b-input-group-prepend>
<b-select v-model="category" :options="categories" />
<b-select :value="category" :options="categories" @change="updateQuery('category', $event)" />
<b-input-group-append>
<b-button variant="primary" :disabled="category === null" @click="category = null">
<b-button variant="primary" :disabled="category === null" @click="updateQuery('category', null)">
{{ $t('app_show_categories') }}
</b-button>
</b-input-group-append>
@ -39,83 +39,86 @@
</b-input-group-prepend>
<b-form-radio-group
id="subtags-radio" name="subtags"
v-model="subtag" :options="subtags"
:checked="subtag" :options="subtags" @change="updateQuery('subtag', $event)"
buttons button-variant="outline-secondary"
/>
<b-select id="subtags-select" v-model="subtag" :options="subtags" />
<b-select
id="subtags-select" :value="subtag" :options="subtags"
@change="updateQuery('subtag', $event)"
/>
</b-input-group>
</div>
</template>
<!-- CATEGORIES CARDS -->
<b-card-group v-if="category === null" deck>
<b-card-group v-if="category === null" deck tag="ul">
<b-card
v-for="cat in categories.slice(1)" :key="cat.value"
class="category-card" no-body
tag="li" class="category-card"
>
<b-button variant="outline-dark" @click="category = cat.value">
<b-card-title>
<b-card-title>
<b-link @click="updateQuery('category', cat.value)" class="card-link">
<icon :iname="cat.icon" /> {{ cat.text }}
</b-card-title>
<b-card-text>{{ cat.description }}</b-card-text>
</b-button>
</b-link>
</b-card-title>
<b-card-text>{{ cat.description }}</b-card-text>
</b-card>
</b-card-group>
<!-- APPS CARDS -->
<b-card-group v-else deck>
<lazy-renderer v-for="app in filteredApps" :key="app.id" :min-height="120">
<b-card no-body>
<b-card-body class="d-flex flex-column">
<b-card-title class="d-flex mb-2">
<card-deck-feed v-else>
<b-card
v-for="(app, i) in filteredApps" :key="app.id"
tag="article" :aria-labelledby="`${app.id}-title`" :aria-describedby="`${app.id}-desc`"
tabindex="0" :aria-posinset="i + 1" :aria-setsize="filteredApps.length"
no-body class="app-card"
>
<b-card-body>
<b-img v-if="app.logo_hash" class="app-logo rounded" :src="`./applogos/${app.logo_hash}.png`" />
<b-card-title :id="`${app.id}-title`" class="d-flex mb-2">
<b-link :to="{ name: 'app-install', params: { id: app.id }}" class="card-link">
{{ app.manifest.name }}
</b-link>
<small v-if="app.state !== 'working' || app.high_quality" class="d-flex align-items-center ml-2">
<b-badge
v-if="app.state !== 'working'"
:variant="app.color"
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
>
<!-- app.state can be 'lowquality' or 'inprogress' -->
{{ $t('app_state_' + app.state) }}
</b-badge>
<small v-if="app.state !== 'working' || app.high_quality" class="d-flex align-items-center ml-2 position-relative">
<b-badge
v-if="app.state !== 'working'"
:variant="app.color"
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
>
<!-- app.state can be 'lowquality' or 'inprogress' -->
{{ $t('app_state_' + app.state) }}
</b-badge>
<icon
v-if="app.high_quality" iname="star" class="star"
v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)"
/>
</small>
</b-card-title>
<icon
v-if="app.high_quality" iname="star" class="star"
v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)"
/>
</small>
</b-card-title>
<b-card-text>{{ app.manifest.description }}</b-card-text>
<b-card-text :id="`${app.id}-desc`">
{{ app.manifest.description }}
</b-card-text>
<b-card-text v-if="!app.maintained" class="align-self-end mt-auto">
<span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')">
<icon iname="warning" /> {{ $t('orphaned') }}
</span>
</b-card-text>
</b-card-body>
<b-card-text v-if="!app.maintained" class="align-self-end position-relative mt-auto">
<span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')">
<icon iname="warning" /> {{ $t('orphaned') }}
</span>
</b-card-text>
</b-card-body>
</b-card>
</card-deck-feed>
<!-- APP BUTTONS -->
<b-button-group>
<b-button :href="app.git.url" variant="outline-dark" target="_blank">
<icon iname="code" /> {{ $t('code') }}
</b-button>
<b-button :href="app.git.url + '/blob/master/README.md'" variant="outline-dark" target="_blank">
<icon iname="book" /> {{ $t('readme') }}
</b-button>
<b-button v-if="app.isInstallable" :variant="app.color" @click="onInstallClick(app)">
<icon iname="plus" /> {{ $t('install') }} <icon v-if="app.color === 'danger'" class="ml-1" iname="warning" />
</b-button>
<b-button v-else :variant="app.color" disabled>
{{ $t('installed') }}
</b-button>
</b-button-group>
</b-card>
</lazy-renderer>
</b-card-group>
<app-catalog-details
v-if="selectedApp"
id="modal-app-info"
:app-id="selectedApp"
:antifeatures="antifeatures"
@ok="onInstallClick(selectedApp)"
@hide="selectedApp = undefined"
/>
<template #bot>
<!-- INSTALL CUSTOM APP -->
@ -160,26 +163,34 @@
<script>
import { validationMixin } from 'vuelidate'
import LazyRenderer from '@/components/LazyRenderer'
import CardDeckFeed from '@/components/CardDeckFeed'
import { required, appRepoUrl } from '@/helpers/validators'
import { randint } from '@/helpers/commons'
export default {
name: 'AppCatalog',
components: {
LazyRenderer
CardDeckFeed
},
props: {
search: { type: String, default: '' },
quality: { type: String, default: 'decent_quality' },
category: { type: String, default: null },
subtag: { type: String, default: 'all' }
},
data () {
return {
queries: [
['GET', 'apps/catalog?full&with_categories']
['GET', 'apps/catalog?full&with_categories&with_antifeatures']
],
// Data
apps: undefined,
selectedApp: undefined,
antifeatures: undefined,
// Filtering options
qualityOptions: [
@ -194,12 +205,6 @@ export default {
// The rest is filled from api data
],
// Set by user inputs
search: '',
category: null,
subtag: 'all',
quality: 'decent_quality',
// Custom install form
customInstall: {
field: {
@ -243,7 +248,7 @@ export default {
subtags () {
// build an options array for subtags v-model/options
if (this.category) {
if (this.category && this.categories.length > 2) {
const category = this.categories.find(cat => cat.value === this.category)
if (category.subtags) {
const subtags = [{ text: this.$i18n.t('all'), value: 'all' }]
@ -298,17 +303,24 @@ export default {
data.categories.forEach(({ title, id, icon, subtags, description }) => {
this.categories.push({ text: title, value: id, icon, subtags, description })
})
this.antifeatures = Object.fromEntries(data.antifeatures.map((af) => ([af.id, af])))
},
setCategory () {
// allow search without selecting a category
if (this.category === null) {
this.category = 'all'
}
updateQuery (key, value) {
// Update the query string without reloading the page
this.$router.replace({
query: {
...this.$route.query,
// allow search without selecting a category
category: this.$route.query.category || 'all',
[key]: value
}
})
},
// INSTALL APP
async onInstallClick (app) {
async onInstallClick (appId) {
const app = this.apps.find((app) => app.id === appId)
if (!app.decent_quality) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + app.state))
if (!confirmed) return
@ -364,28 +376,43 @@ export default {
}
.card-deck {
padding: 0;
margin-bottom: 0;
> * {
margin-left: 15px;
margin-right: 15px;
margin-bottom: 2rem;
flex-basis: 100%;
@include media-breakpoint-up(md) {
flex-basis: 50%;
max-width: calc(50% - 30px);
}
@include media-breakpoint-up(lg) {
flex-basis: 33%;
max-width: calc(33.3% - 30px);
}
.card {
margin: 0;
height: 100%;
}
}
.card {
border-color: $gray-400;
@include hover() {
color: color-yiq($dark);
background-color: $dark;
border-color: $dark;
}
.card-link {
color: inherit;
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
}
// not maintained info
.alert-warning {
@ -403,37 +430,39 @@ export default {
}
flex-basis: 90%;
border: 0;
.btn {
padding: 1rem;
width: 100%;
height: 100%;
.card-body {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.card-link {
outline: none;
&::after {
border: $btn-border-width solid transparent;
@include transition($btn-transition);
@include border-radius($btn-border-radius, 0);
}
&:focus::after {
box-shadow: 0 0 0 $btn-focus-width rgba($dark, .5);
}
}
}
.btn-group {
.btn {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 0;
flex-basis: 0;
padding-left: 0;
padding-right: 0;
}
.btn:first-of-type {
border-left: 0;
}
.btn:last-of-type {
border-right: 0;
}
}
.app-card {
min-height: 125px;
text-align: start;
background-color: $gray-200;
.btn-outline-dark {
border-color: $gray-400;
&:hover {
border-color: $dark;
.app-logo {
float: left;
background-color: white;
max-width: 91px;
margin-right: 1rem;
}
}
}

View file

@ -1,76 +0,0 @@
<template>
<view-base
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton"
>
<config-panels
v-if="config.panels" v-bind="config"
@submit="onConfigSubmit"
/>
<b-alert v-else-if="config.panels === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }}
</b-alert>
</view-base>
</template>
<script>
import api, { objectToParams } from '@/api'
import {
formatFormData,
formatYunoHostConfigPanels
} from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels'
export default {
name: 'AppConfigPanel',
components: {
ConfigPanels
},
props: {
id: { type: String, required: true }
},
data () {
return {
queries: [
['GET', `apps/${this.id}/config?full`]
],
config: {}
}
},
methods: {
onQueriesResponse (config) {
if (!config.panels || config.panels.length === 0) {
this.config = null
} else {
this.config = formatYunoHostConfigPanels(config)
}
},
async onConfigSubmit ({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
api.put(
action
? `apps/${this.id}/actions/${action}`
: `apps/${this.id}/config/${id}`,
{ args: objectToParams(args) },
{ key: `apps.${action ? 'action' : 'update'}_config`, id, name: this.id }
).then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
}
}
}
</script>

View file

@ -1,137 +1,253 @@
<template>
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<!-- BASIC INFOS -->
<card v-if="infos" :title="infos.label" icon="cube">
<description-row
v-for="(value, key) in infos" :key="key"
:term="$t(key)"
>
<a v-if="key === 'url'" :href="value" target="_blank">{{ value }}</a>
<template v-else>{{ value }}</template>
</description-row>
<description-row :term="$t('app_info_access_desc')">
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') + '.' : $t('nobody') }}
<yuno-alert v-if="app && app.doc && app.doc.notifications && app.doc.notifications.postInstall.length" variant="info" class="my-4">
<div class="d-md-flex align-items-center mb-3">
<h2 v-t="'app.doc.notifications.post_install'" class="md-m-0" />
<b-button
size="sm" :to="{ name: 'group-list'}" variant="info"
class="ml-2"
variant="primary"
size="sm"
class="ml-auto mr-2"
@click="dismissNotification('post_install')"
>
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
<icon iname="check" />
{{ $t('app.doc.notifications.dismiss') }}
</b-button>
</description-row>
</card>
</div>
<!-- OPERATIONS -->
<card v-if="app" :title="$t('operations')" icon="wrench">
<!-- CHANGE PERMISSIONS LABEL -->
<b-form-group :label="$t('app_manage_label_and_tiles')" label-class="font-weight-bold">
<form-field
v-for="(perm, i) in app.permissions" :key="i"
:label="perm.title" :label-for="'perm-' + i"
label-cols="0" label-class="" class="m-0"
:validation="$v.form.labels.$each[i] "
<vue-showdown
v-for="[name, notif] in app.doc.notifications.postUpgrade" :key="name"
:markdown="notif" flavor="github" :options="{ headerLevelStart: 4 }"
/>
</yuno-alert>
<yuno-alert v-if="app && app.doc && app.doc.notifications && app.doc.notifications.postUpgrade.length" variant="info" class="my-4">
<div class="d-md-flex align-items-center mb-3">
<h2 v-t="'app.doc.notifications.post_upgrade'" class="md-m-0" />
<b-button
variant="primary"
size="sm"
class="ml-auto mr-2"
@click="dismissNotification('post_upgrade')"
>
<template #default="{ self }">
<b-input-group>
<input-item
:state="self.state" v-model="form.labels[i].label"
:id="'perm' + i" :aria-describedby="'perm-' + i + '_group__BV_description_'"
/>
<b-input-group-append v-if="perm.tileAvailable" is-text>
<checkbox-item v-model="form.labels[i].show_tile" :label="$t('permission_show_tile_enabled')" />
</b-input-group-append>
<b-input-group-append>
<b-button
variant="info" v-t="'save'"
@click="changeLabel(perm.name, form.labels[i])"
<icon iname="check" />
{{ $t('app.doc.notifications.dismiss') }}
</b-button>
</div>
<vue-showdown
v-for="[name, notif] in app.doc.notifications.postUpgrade" :key="name"
:markdown="notif" flavor="github" :options="{ headerLevelStart: 4 }"
/>
</yuno-alert>
<section v-if="app" class="border rounded p-3 mb-4">
<div class="d-md-flex align-items-center mb-4">
<h1 class="mb-3 mb-md-0">
<icon iname="cube" />
{{ app.label }}
<span class="text-secondary tiny">
{{ app.id }}
</span>
</h1>
<b-button
v-if="app.url"
:href="app.url" target="_blank"
variant="success" class="ml-auto mr-2"
>
<icon iname="external-link" />
{{ $t('app.open_this_app') }}
</b-button>
<b-button
@click="uninstall"
id="uninstall"
variant="danger"
:class="{ 'ml-auto': !app.url }"
>
<icon iname="trash-o" />
{{ $t('uninstall') }}
</b-button>
</div>
<p class="text-secondary">
{{ $t('app.installed_version', { version: app.version }) }}<br>
<template v-if="app.alternativeTo">
{{ $t('app.potential_alternative_to') }} {{ app.alternativeTo }}
</template>
</p>
<p>
<icon iname="comments" /> {{ $t('app.info.problem') }}
<a :href="`https://forum.yunohost.org/tag/${id}`" target="_blank">
{{ $t('app.info.forum') }}
</a>
</p>
<vue-showdown :markdown="app.description" flavor="github" />
</section>
<!-- BASIC INFOS -->
<config-panels v-bind="config" @submit="onConfigSubmit">
<!-- OPERATIONS TAB -->
<template v-if="currentTab === 'operations'" #tab-top>
<!-- CHANGE PERMISSIONS LABEL -->
<b-form-group :label="$t('app_manage_label_and_tiles')" label-class="font-weight-bold">
<form-field
v-for="(perm, i) in app.permissions" :key="i"
:label="perm.title" :label-for="'perm-' + i"
label-cols="0" label-class="" class="m-0"
:validation="$v.form.labels.$each[i] "
>
<template #default="{ self }">
<b-input-group>
<input-item
:state="self.state" v-model="form.labels[i].label"
:id="'perm' + i" :aria-describedby="'perm-' + i + '_group__BV_description_'"
/>
</b-input-group-append>
</b-input-group>
</template>
<b-input-group-append v-if="perm.tileAvailable" is-text>
<checkbox-item v-model="form.labels[i].show_tile" :label="$t('permission_show_tile_enabled')" />
</b-input-group-append>
<b-input-group-append>
<b-button
variant="info" v-t="'save'"
@click="changeLabel(perm.name, form.labels[i])"
/>
</b-input-group-append>
</b-input-group>
</template>
<template v-if="perm.url" #description>
{{ $t('permission_corresponding_url') }}:
<b-link :href="'https://' + perm.url">
https://{{ perm.url }}
</b-link>
</template>
</form-field>
</b-form-group>
<hr>
<template v-if="perm.url" #description>
{{ $t('permission_corresponding_url') }}:
<b-link :href="'https://' + perm.url">
https://{{ perm.url }}
</b-link>
</template>
</form-field>
</b-form-group>
<hr>
<!-- CHANGE URL -->
<b-form-group
:label="$t('app_info_changeurl_desc')" label-for="input-url"
:label-cols-lg="app.supports_change_url ? 0 : 0" label-class="font-weight-bold"
v-if="app.is_webapp"
>
<b-input-group v-if="app.supports_change_url">
<b-input-group-prepend is-text>
https://
</b-input-group-prepend>
<b-input-group-prepend class="flex-grow-1">
<b-select v-model="form.url.domain" :options="domains" />
</b-input-group-prepend>
<b-input-group-prepend is-text>
/
</b-input-group-prepend>
<b-input id="input-url" v-model="form.url.path" class="flex-grow-3" />
<b-input-group-append>
<b-button @click="changeUrl" variant="info" v-t="'save'" />
</b-input-group-append>
</b-input-group>
<div v-else class="alert alert-warning">
<icon iname="exclamation" /> {{ $t('app_info_change_url_disabled_tooltip') }}
</div>
</b-form-group>
<hr v-if="app.is_webapp">
<!-- MAKE DEFAULT -->
<b-form-group
:label="$t('app_info_default_desc', { domain: app.domain })" label-for="main-domain"
label-class="font-weight-bold" label-cols-md="4"
v-if="app.is_webapp"
>
<template v-if="!app.is_default">
<b-button @click="setAsDefaultDomain($event, false)" id="main-domain" variant="success">
<icon iname="star" /> {{ $t('app_make_default') }}
</b-button>
</template>
<template v-else>
<b-button @click="setAsDefaultDomain($event, true)" id="main-domain" variant="warning">
<icon iname="star" /> {{ $t('app_make_not_default') }}
</b-button>
</template>
</b-form-group>
<hr v-if="app.is_webapp">
<!-- APP CONFIG PANEL -->
<template v-if="app.supports_config_panel">
<!-- PERMISSIONS -->
<b-form-group
:label="$t('app_config_panel_label')" label-for="config"
label-cols-md="4" label-class="font-weight-bold"
:label="$t('app_info_access_desc')" label-for="permissions"
label-class="font-weight-bold" label-cols-lg="0"
>
<b-button id="config" variant="warning" :to="{ name: 'app-config-panel', params: { id } }">
<icon iname="cog" /> {{ $t('app_config_panel') }}
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') : $t('nobody') }}
<b-button
size="sm" :to="{ name: 'group-list'}" variant="info"
class="ml-2"
>
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
</b-button>
</b-form-group>
<hr>
</template>
<!-- UNINSTALL -->
<b-form-group
:label="$t('app_info_uninstall_desc')" label-for="uninstall"
label-class="font-weight-bold" label-cols-md="4"
>
<b-button @click="uninstall" id="uninstall" variant="danger">
<icon iname="trash-o" /> {{ $t('uninstall') }}
</b-button>
</b-form-group>
<!-- CHANGE URL -->
<b-form-group
:label="$t('app_info_changeurl_desc')" label-for="input-url"
:label-cols-lg="app.supports_change_url ? 0 : 0" label-class="font-weight-bold"
v-if="app.is_webapp"
>
<b-input-group v-if="app.supports_change_url">
<b-input-group-prepend is-text>
https://
</b-input-group-prepend>
<b-input-group-prepend class="flex-grow-1">
<b-select v-model="form.url.domain" :options="domains" />
</b-input-group-prepend>
<b-input-group-prepend is-text>
/
</b-input-group-prepend>
<b-input id="input-url" v-model="form.url.path" class="flex-grow-3" />
<b-input-group-append>
<b-button @click="changeUrl" variant="info" v-t="'save'" />
</b-input-group-append>
</b-input-group>
<div v-else class="alert alert-warning">
<icon iname="exclamation" /> {{ $t('app_info_change_url_disabled_tooltip') }}
</div>
</b-form-group>
<hr v-if="app.is_webapp">
<!-- MAKE DEFAULT -->
<b-form-group
:label="$t('app_info_default_desc', { domain: app.domain })" label-for="main-domain"
label-class="font-weight-bold" label-cols-md="4"
v-if="app.is_webapp"
>
<template v-if="!app.is_default">
<b-button @click="setAsDefaultDomain(false)" id="main-domain" variant="success">
<icon iname="star" /> {{ $t('app_make_default') }}
</b-button>
</template>
<template v-else>
<b-button @click="setAsDefaultDomain(true)" id="main-domain" variant="warning">
<icon iname="star" /> {{ $t('app_make_not_default') }}
</b-button>
</template>
</b-form-group>
</template>
</config-panels>
<b-card v-if="app && app.doc.admin.length" no-body>
<b-tabs card fill pills>
<b-tab
v-for="[name, content] in app.doc.admin" :key="name"
>
<template #title>
<icon iname="book" class="mr-2" />
{{ name === "admin" ? $t('app.doc.admin.title') : name }}
</template>
<vue-showdown :markdown="content" flavor="github" />
</b-tab>
</b-tabs>
</b-card>
<card
v-if="app && app.integration"
id="app-integration" :title="$t('app.integration.title')"
collapsable collapsed no-body
>
<b-list-group flush>
<yuno-list-group-item variant="info">
{{ $t('app.integration.archs') }} {{ app.integration.archs }}
</yuno-list-group-item>
<yuno-list-group-item v-if="app.integration.ldap" :variant="app.integration.ldap === true ? 'success' : 'warning'">
{{ $t(`app.integration.ldap.${app.integration.ldap}`) }}
</yuno-list-group-item>
<yuno-list-group-item v-if="app.integration.sso" :variant="app.integration.sso === true ? 'success' : 'warning'">
{{ $t(`app.integration.sso.${app.integration.sso}`) }}
</yuno-list-group-item>
<yuno-list-group-item variant="info">
{{ $t(`app.integration.multi_instance.${app.integration.multi_instance}`) }}
</yuno-list-group-item>
<yuno-list-group-item variant="info">
{{ $t('app.integration.resources', app.integration.resources) }}
</yuno-list-group-item>
</b-list-group>
</card>
<card
v-if="app"
id="app-links" icon="link" :title="$t('app.links.title')"
collapsable collapsed no-body
>
<b-list-group flush>
<yuno-list-group-item v-for="[key, link] in app.links" :key="key" no-status>
<b-link :href="link" target="_blank">
<icon :iname="appLinksIcons(key)" />
{{ $t('app.links.' + key) }}
</b-link>
</yuno-list-group-item>
</b-list-group>
</card>
<template #skeleton>
@ -145,14 +261,24 @@
import { mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate'
import api from '@/api'
import api, { objectToParams } from '@/api'
import { readableDate } from '@/helpers/filters/date'
import { humanPermissionName } from '@/helpers/filters/human'
import { required } from '@/helpers/validators'
import {
formatFormData,
formatI18nField,
formatYunoHostConfigPanels
} from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels'
export default {
name: 'AppInfo',
components: {
ConfigPanels
},
props: {
id: { type: String, required: true }
},
@ -162,17 +288,33 @@ export default {
queries: [
['GET', `apps/${this.id}?full`],
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
['GET', { uri: 'domains' }]
['GET', { uri: 'domains' }],
['GET', `apps/${this.id}/config?full`]
],
infos: undefined,
app: undefined,
form: undefined
form: undefined,
config: {
panels: [
// Fake integration of operations in config panels
{
hasApplyButton: false,
id: 'operations',
name: this.$i18n.t('operations')
}
],
validations: {}
},
doc: undefined
}
},
computed: {
...mapGetters(['domains']),
currentTab () {
return this.$route.params.tabId
},
allowedGroups () {
if (!this.app) return
return this.app.permissions[0].allowed
@ -191,7 +333,26 @@ export default {
},
methods: {
onQueriesResponse (app) {
appLinksIcons (linkType) {
const linksIcons = {
license: 'institution',
website: 'globe',
admindoc: 'book',
userdoc: 'book',
code: 'code',
package: 'code',
forum: 'comments'
}
return linksIcons[linkType]
},
onQueriesResponse (app, _, __, config) {
if (app.supports_config_panel) {
const config_ = formatYunoHostConfigPanels(config)
// reinject 'operations' fake config tab
config_.panels.unshift(this.config.panels[0])
this.config = config_
}
const form = { labels: [] }
const mainPermission = app.permissions[this.id + '.main']
@ -213,35 +374,81 @@ export default {
form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile })
}
}
this.infos = {
id: this.id,
label: mainPermission.label,
description: app.description,
version: app.version,
multi_instance: this.$i18n.t(app.manifest.integration.multi_instance ? 'yes' : 'no'),
install_time: readableDate(app.settings.install_time, true, true)
}
if (app.settings.domain && app.settings.path) {
this.infos.url = 'https://' + app.settings.domain + app.settings.path
form.url = {
domain: app.settings.domain,
path: app.settings.path.slice(1)
}
}
this.form = form
const { DESCRIPTION, ADMIN, ...doc } = app.manifest.doc
const notifs = app.manifest.notifications
const { ldap, sso, multi_instance, ram, disk, architectures: archs } = app.manifest.integration
this.app = {
id: this.id,
version: app.version,
label: mainPermission.label,
domain: app.settings.domain,
alternativeTo: app.from_catalog.potential_alternative_to?.length
? app.from_catalog.potential_alternative_to.join(this.$i18n.t('words.separator'))
: null,
description: DESCRIPTION ? formatI18nField(DESCRIPTION) : app.description,
integration: app.manifest.packaging_format >= 2 ? {
archs: Array.isArray(archs) ? archs.join(this.$i18n.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.manifest.upstream.license}`],
...['website', 'admindoc', 'userdoc', 'code'].map((key) => ([key, app.manifest.upstream[key]])),
['forum', `https://forum.yunohost.org/tag/${this.id}`]
].filter(([key, val]) => !!val),
doc: {
notifications: {
postInstall: notifs.POST_INSTALL && notifs.POST_INSTALL.main ? [['main', formatI18nField(notifs.POST_INSTALL.main)]] : [],
postUpgrade: notifs.POST_UPGRADE ? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => {
return [key, formatI18nField(content)]
}) : []
},
admin: [
['admin', formatI18nField(ADMIN)],
...Object.keys(doc).sort().map((key) => [key.charAt(0) + key.slice(1).toLowerCase(), formatI18nField(doc[key])])
].filter((doc) => doc[1])
},
is_webapp: app.is_webapp,
is_default: app.is_default,
supports_change_url: app.supports_change_url,
supports_config_panel: app.supports_config_panel,
permissions
}
if (this.app.is_webapp) {
this.app.is_default = app.is_default
if (app.settings.domain && app.settings.path) {
this.app.url = 'https://' + app.settings.domain + app.settings.path
form.url = {
domain: app.settings.domain,
path: app.settings.path.slice(1)
}
}
if (!Object.values(this.app.doc.notifications).some((notif) => notif.length)) {
this.app.doc.notifications = null
}
},
async onConfigSubmit ({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
api.put(
action
? `apps/${this.id}/actions/${action}`
: `apps/${this.id}/config/${id}`,
{ args: objectToParams(args) },
{ key: `apps.${action ? 'action' : 'update'}_config`, id, name: this.id }
).then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
},
changeLabel (permName, data) {
@ -249,7 +456,7 @@ export default {
api.put(
'users/permissions/' + permName,
data,
{ key: 'apps.change_label', prevName: this.infos.label, nextName: data.label }
{ key: 'apps.change_label', prevName: this.app.label, nextName: data.label }
).then(this.$refs.view.fetchQueries)
},
@ -261,18 +468,26 @@ export default {
api.put(
`apps/${this.id}/changeurl`,
{ domain, path: '/' + path },
{ key: 'apps.change_url', name: this.infos.label }
{ key: 'apps.change_url', name: this.app.label }
).then(this.$refs.view.fetchQueries)
},
async setAsDefaultDomain (event, undo = false) {
async setAsDefaultDomain (undo = false) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_default'))
if (!confirmed) return
api.put(
`apps/${this.id}/default${undo ? '?undo' : ''}`,
{},
{ key: 'apps.set_default', name: this.infos.label, domain: this.app.domain }
{ key: 'apps.set_default', name: this.app.label, domain: this.app.domain }
).then(this.$refs.view.fetchQueries)
},
async dismissNotification (name) {
api.put(
`apps/${this.id}/dismiss_notification/${name}`,
{},
{ key: 'apps.dismiss_notification', name: this.app.label }
).then(this.$refs.view.fetchQueries)
},
@ -282,7 +497,7 @@ export default {
)
if (!confirmed) return
api.delete('apps/' + this.id, {}, { key: 'apps.uninstall', name: this.infos.label }).then(() => {
api.delete('apps/' + this.id, {}, { key: 'apps.uninstall', name: this.app.label }).then(() => {
this.$router.push({ name: 'app-list' })
})
}
@ -302,4 +517,13 @@ select {
.input-group input {
min-width: 5rem;
}
.tiny {
font-size: 50%;
font-weight: normal;
}
.yuno-alert div div:not(:last-child) {
margin-bottom: 1rem;
}
</style>

View file

@ -1,16 +1,135 @@
<template>
<view-base :queries="queries" @queries-response="onQueriesResponse">
<template v-if="infos">
<!-- BASIC INFOS -->
<card :title="name" icon="download">
<description-row
v-for="(info, key) in infos" :key="key"
:term="$t(key)" :details="info"
<template v-if="app">
<section class="border rounded p-3 mb-4">
<div class="d-md-flex align-items-center mb-4">
<h1 class="mb-3 mb-md-0">
{{ app.name }}
</h1>
<b-button
v-if="app.demo"
:href="app.demo" target="_blank"
variant="primary" class="ml-auto"
>
<icon iname="external-link" />
{{ $t('app.install.try_demo') }}
</b-button>
</div>
<p class="text-secondary">
{{ $t('app.install.version', { version: app.version }) }}<br>
<template v-if="app.alternativeTo">
{{ $t('app.potential_alternative_to') }} {{ app.alternativeTo }}
</template>
</p>
<vue-showdown :markdown="app.description" flavor="github" />
<b-img
v-if="app.screenshot"
:src="app.screenshot"
aria-hidden="true" class="d-block" fluid
/>
</section>
<card
v-if="app.integration"
id="app-integration" :title="$t('app.integration.title')"
collapsable collapsed no-body
>
<b-list-group flush>
<yuno-list-group-item variant="info">
{{ $t('app.integration.archs') }} {{ app.integration.archs }}
</yuno-list-group-item>
<yuno-list-group-item v-if="app.integration.ldap" :variant="app.integration.ldap === true ? 'success' : 'warning'">
{{ $t(`app.integration.ldap.${app.integration.ldap}`) }}
</yuno-list-group-item>
<yuno-list-group-item v-if="app.integration.sso" :variant="app.integration.sso === true ? 'success' : 'warning'">
{{ $t(`app.integration.sso.${app.integration.sso}`) }}
</yuno-list-group-item>
<yuno-list-group-item variant="info">
{{ $t(`app.integration.multi_instance.${app.integration.multi_instance}`) }}
</yuno-list-group-item>
<yuno-list-group-item variant="info">
{{ $t('app.integration.resources', app.integration.resources) }}
</yuno-list-group-item>
</b-list-group>
</card>
<card
id="app-links" icon="link" :title="$t('app.links.title')"
collapsable collapsed no-body
>
<template #header>
<h2><icon iname="link" /> {{ $t('app.links.title') }}</h2>
</template>
<b-list-group flush>
<yuno-list-group-item v-for="[key, link] in app.links" :key="key" no-status>
<b-link :href="link" target="_blank">
<icon :iname="appLinksIcons(key)" />
{{ $t('app.links.' + key) }}
</b-link>
</yuno-list-group-item>
</b-list-group>
</card>
<yuno-alert v-if="app.hasWarning" variant="warning" class="my-4">
<h2>{{ $t('app.install.notifs.pre.warning') }}</h2>
<template v-if="app.antifeatures">
<strong v-t="'app.antifeatures'" class="d-block mb-2" />
<dl class="antifeatures">
<div v-for="antifeature in app.antifeatures" :key="antifeature.id">
<dt class="d-inline">
<icon :iname="antifeature.icon" class="md mr-1" />
{{ antifeature.title }}:
</dt>
<dd class="d-inline">
{{ antifeature.description }}
</dd>
</div>
</dl>
</template>
<p v-if="app.quality.state === 'lowquality'" v-t="'app.install.problems.lowquality'" />
<vue-showdown v-if="app.preInstall" :markdown="app.preInstall" flavor="github" />
</yuno-alert>
<yuno-alert
v-if="!app.hasSupport"
variant="danger" icon="warning" class="my-4"
>
<h2>{{ $t('app.install.notifs.pre.critical') }}</h2>
<p v-if="!app.requirements.arch.pass">
{{ $t('app.install.problems.arch', app.requirements.arch.values) }}
</p>
<p v-if="!app.requirements.install.pass">
{{ $t('app.install.problems.install', app.requirements.install.values) }}
</p>
<p v-if="!app.requirements.required_yunohost_version.pass">
{{ $t('app.install.problems.version', app.requirements.required_yunohost_version.values) }}
</p>
</yuno-alert>
<yuno-alert v-else-if="app.hasDanger" variant="danger" class="my-4">
<h2>{{ $t('app.install.notifs.pre.danger') }}</h2>
<p v-if="['inprogress', 'broken', 'thirdparty'].includes(app.quality.state)" v-t="'app.install.problems.' + app.quality.state" />
<p v-if="!app.requirements.ram.pass">
{{ $t('app.install.problems.ram', app.requirements.ram.values) }}
</p>
<checkbox-item v-model="force" id="force-install" :label="$t('app.install.problems.ignore')" />
</yuno-alert>
<!-- INSTALL FORM -->
<card-form
v-if="app.canInstall || force"
:title="$t('app_install_parameters')" icon="cog" :submit-text="$t('install')"
:validation="$v" :server-error="serverError"
@submit.prevent="performInstall"
@ -25,7 +144,7 @@
</template>
<!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="infos === null" variant="warning">
<b-alert v-else-if="app === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_install_custom_no_manifest') }}
</b-alert>
@ -45,12 +164,17 @@ import {
formatI18nField,
formatFormData
} from '@/helpers/yunohostArguments'
import CardCollapse from '@/components/CardCollapse'
export default {
name: 'AppInstall',
mixins: [validationMixin],
components: {
CardCollapse
},
props: {
id: { type: String, required: true }
},
@ -58,15 +182,17 @@ export default {
data () {
return {
queries: [
['GET', 'apps/manifest?app=' + this.id]
['GET', 'apps/catalog?full&with_categories&with_antifeatures'],
['GET', `apps/manifest?app=${this.id}&with_screenshot`]
],
app: undefined,
name: undefined,
infos: undefined,
form: undefined,
fields: undefined,
validations: null,
errors: undefined,
serverError: ''
serverError: '',
force: false
}
},
@ -75,21 +201,86 @@ export default {
},
methods: {
onQueriesResponse (manifest) {
this.name = manifest.name
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
manifest.license = manifest.upstream.license
if (manifest.license === undefined || manifest.license === 'free') {
infosKeys.splice(2, 1)
appLinksIcons (linkType) {
const linksIcons = {
license: 'institution',
website: 'globe',
admindoc: 'book',
userdoc: 'book',
code: 'code',
package: 'code',
forum: 'comments'
}
return linksIcons[linkType]
},
onQueriesResponse (catalog, _app) {
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(this.$i18n.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(this.$i18n.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) => ([key, _app.upstream[key]])),
['package', _app.remote.url],
['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
}
manifest.description = formatI18nField(manifest.description)
manifest.multi_instance = this.$i18n.t(manifest.integration.multi_instance ? 'yes' : 'no')
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
// FIXME yunohost should add the label field by default
manifest.install.unshift({
ask: this.$t('label_for_manifestname', { name: manifest.name }),
default: manifest.name,
_app.install.unshift({
ask: this.$t('label_for_manifestname', { name }),
default: name,
name: 'label',
help: this.$t('label_for_manifestname_help')
})
@ -99,14 +290,21 @@ export default {
fields,
validations,
errors
} = formatYunoHostArguments(manifest.install)
} = formatYunoHostArguments(_app.install)
this.app = app
this.fields = fields
this.form = form
this.validations = { form: validations }
this.errors = errors
},
formatAppNotifs (notifs) {
return Object.keys(notifs).reduce((acc, key) => {
return acc + '\n\n' + notifs[key]
}, '')
},
async performInstall () {
if ('path' in this.form && this.form.path === '/') {
const confirmed = await this.$askConfirmation(
@ -121,7 +319,15 @@ export default {
)
const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }
api.post('apps', data, { key: 'apps.install', name: this.name }).then(() => {
api.post('apps', data, { key: 'apps.install', name: this.app.name }).then(async ({ notifications }) => {
const postInstall = this.formatAppNotifs(notifications)
if (postInstall) {
const message = this.$i18n.t('app.install.notifs.post.alert') + '\n\n' + postInstall
await this.$askMdConfirmation(message, {
title: this.$i18n.t('app.install.notifs.post.title', { name: this.app.name }),
okTitle: this.$i18n.t('ok')
}, true)
}
this.$router.push({ name: 'app-list' })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
@ -133,3 +339,11 @@ export default {
}
}
</script>
<style lang="scss" scoped>
.antifeatures {
dt::before {
content: "• ";
}
}
</style>

View file

@ -23,7 +23,7 @@
<div>
<h5 class="font-weight-bold">
{{ label }}
<small v-if="name" class="text-secondary">{{ name }}</small>
<small class="text-secondary">{{ id }}</small>
</h5>
<p class="m-0">
{{ description }}

View file

@ -4,14 +4,14 @@
skeleton="card-list-skeleton"
>
<!-- MIGRATIONS WARN -->
<b-alert variant="warning" :show="pendingMigrations">
<icon iname="exclamation-triangle" /> <span v-html="$t('pending_migrations')" />
</b-alert>
<yuno-alert v-if="pendingMigrations" variant="warning" alert>
<span v-html="$t('pending_migrations')" />
</yuno-alert>
<!-- MAJOR YUNOHOST UPGRADE WARN -->
<b-alert variant="warning" :show="importantYunohostUpgrade">
<icon iname="exclamation-triangle" /> <span v-html="$t('important_yunohost_upgrade')" />
</b-alert>
<yuno-alert v-if="importantYunohostUpgrade" variant="warning" alert>
<span v-html="$t('important_yunohost_upgrade')" />
</yuno-alert>
<!-- SYSTEM UPGRADE -->
<card :title="$t('system')" icon="server" no-body>
@ -31,7 +31,7 @@
<template #buttons v-if="system">
<b-button
variant="success" v-t="'system_upgrade_all_packages_btn'"
@click="performUpgrade({ type: 'system' })"
@click="performSystemUpgrade()"
/>
</template>
</card>
@ -50,7 +50,7 @@
<b-button
variant="success" size="sm" v-t="'system_upgrade_btn'"
@click="performUpgrade({ type: 'specific_app', id })"
@click="confirmAppsUpgrade(id)"
/>
</b-list-group-item>
</b-list-group>
@ -62,19 +62,67 @@
<template #buttons v-if="apps">
<b-button
variant="success" v-t="'system_upgrade_all_applications_btn'"
@click="performUpgrade({ type: 'apps' })"
@click="confirmAppsUpgrade()"
/>
</template>
</card>
<b-modal
id="apps-pre-upgrade"
:title="$t('app.upgrade.confirm.title')"
header-bg-variant="warning"
:header-class="theme ? 'text-white' : 'text-black'"
:ok-title="$t('system_upgrade_btn')" ok-variant="success"
:cancel-title="$t('cancel')"
@ok="performAppsUpgrade(preUpgrade.apps.map((app) => app.id))"
>
<h3>
{{ $t('app.upgrade.confirm.apps') }}
</h3>
<ul>
<li v-for="{ name, id } in preUpgrade.apps" :key="id">
{{ name }} ({{ id }})
</li>
</ul>
<div v-if="preUpgrade.hasNotifs" class="mt-4">
<h3>
{{ $t('app.upgrade.notifs.pre.title') }}
</h3>
<yuno-alert variant="warning">
{{ $t('app.upgrade.notifs.pre.alert' ) }}
</yuno-alert>
<div class="card-collapse-wrapper">
<card-collapse
v-for="{ id, name, notif } in preUpgrade.apps" :key="`${id}-notifs`"
:title="name" :id="`${id}-notifs`"
visible flush
>
<b-card-body>
<vue-showdown :markdown="notif" flavor="github" :options="{ headerLevelStart: 6 }" />
</b-card-body>
</card-collapse>
</div>
</div>
</b-modal>
</view-base>
</template>
<script>
import api from '@/api'
import { mapGetters } from 'vuex'
import CardCollapse from '@/components/CardCollapse'
export default {
name: 'SystemUpdate',
components: {
CardCollapse
},
data () {
return {
queries: [
@ -84,10 +132,18 @@ export default {
system: undefined,
apps: undefined,
importantYunohostUpgrade: undefined,
pendingMigrations: undefined
pendingMigrations: undefined,
preUpgrade: {
apps: [],
notifs: []
}
}
},
computed: {
...mapGetters(['theme'])
},
methods: {
// eslint-disable-next-line camelcase
onQueriesResponse ({ apps, system, important_yunohost_upgrade, pending_migrations }) {
@ -98,25 +154,85 @@ export default {
this.pendingMigrations = pending_migrations.length !== 0
},
async performUpgrade ({ type, id = null }) {
const confirmMsg = this.$i18n.t('confirm_update_' + type, id ? { app: id } : {})
const confirmed = await this.$askConfirmation(confirmMsg)
formatAppNotifs (notifs) {
return Object.keys(notifs).reduce((acc, key) => {
return acc + '\n\n' + notifs[key]
}, '')
},
async confirmAppsUpgrade (id = null) {
const appList = id ? [this.apps.find((app) => app.id === id)] : this.apps
const apps = appList.map((app) => ({
id: app.id,
name: app.name,
notif: app.notifications.PRE_UPGRADE
? this.formatAppNotifs(app.notifications.PRE_UPGRADE)
: ''
}))
this.preUpgrade = { apps, hasNotifs: apps.some((app) => app.notif) }
this.$bvModal.show('apps-pre-upgrade')
},
async performAppsUpgrade (ids) {
const apps = ids.map((id) => this.apps.find((app) => app.id === id))
const lastAppId = apps[apps.length - 1].id
for (const app of apps) {
const continue_ = await api.put(
`apps/${app.id}/upgrade`, {}, { key: 'upgrade.app', app: app.name }
).then((response) => {
const postMessage = this.formatAppNotifs(response.notifications.POST_UPGRADE)
const isLast = app.id === lastAppId
this.apps = this.apps.filter((a) => app.id !== a.id)
if (postMessage) {
const message = this.$i18n.t('app.upgrade.notifs.post.alert') + '\n\n' + postMessage
return this.$askMdConfirmation(message, {
title: this.$i18n.t('app.upgrade.notifs.post.title', { name: app.name }),
okTitle: this.$i18n.t(isLast ? 'ok' : 'app.upgrade.continue'),
cancelTitle: this.$i18n.t('app.upgrade.stop')
}, isLast)
} else {
return Promise.resolve(true)
}
})
if (!continue_) break
}
if (!this.apps.length) {
this.apps = null
}
},
async performSystemUpgrade () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_update_system'))
if (!confirmed) return
const uri = id !== null ? `apps/${id}/upgrade` : 'upgrade/' + type
api.put(uri, {}, { key: 'upgrade.' + (id ? 'app' : type), app: id }).then(() => {
if (id !== null) {
this.apps = this.apps.filter(app => id !== app.id)
} else if (type === 'apps') {
this.apps = null
} else {
if (this.system.some(({ name }) => name.includes('yunohost'))) {
this.$store.dispatch('TRY_TO_RECONNECT', { attemps: 1, origin: 'upgrade_system', initialDelay: 2000 })
}
this.system = null
api.put('upgrade/system', {}, { key: 'upgrade.system' }).then(() => {
if (this.system.some(({ name }) => name.includes('yunohost'))) {
this.$store.dispatch('TRY_TO_RECONNECT', { attemps: 1, origin: 'upgrade_system', initialDelay: 2000 })
}
this.system = null
})
}
}
}
</script>
<style scoped lang="scss">
.card-collapse-wrapper {
border: $card-border-width solid $card-border-color;
border-radius: $card-border-radius;
.card {
&:first-child {
border-top: 0;
border-top-right-radius: $card-border-radius;
border-top-left-radius: $card-border-radius;
}
&:last-child {
border-bottom: 0;
}
}
}
</style>