Merge pull request #18969 from nextcloud/enh/apps/use-sidebar

Migrate app management settings to proper sidebar standards
This commit is contained in:
Morris Jobke 2020-08-14 15:44:04 +02:00 committed by GitHub
commit 7d550e3679
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 289 additions and 173 deletions

View File

@ -663,8 +663,6 @@ span.version {
} }
.app-level { .app-level {
margin-top: 8px;
span { span {
color: var(--color-text-maxcontrast); color: var(--color-text-maxcontrast);
background-color: transparent; background-color: transparent;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -21,116 +21,74 @@
--> -->
<template> <template>
<div id="app-details-view" style="padding: 20px;"> <div class="app-details">
<h2> <div class="app-details__actions">
<div v-if="!app.preview" class="icon-settings-dark" /> <div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups">
<svg v-if="app.previewAsIcon && app.preview" <input :id="prefix('groups_enable', app.id)"
width="32" v-model="groupCheckedAppsData"
height="32" type="checkbox"
viewBox="0 0 32 32"> :value="app.id"
<defs><filter :id="filterId"><feColorMatrix in="SourceGraphic" type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0" /></filter></defs> class="groups-enable__checkbox checkbox"
<image x="0" @change="setGroupLimit">
y="0" <label :for="prefix('groups_enable', app.id)">{{ t('settings', 'Limit to groups') }}</label>
width="32" <input type="hidden"
height="32" class="group_select"
preserveAspectRatio="xMinYMin meet" :title="t('settings', 'All')"
:filter="filterUrl" value="">
:xlink:href="app.preview" <Multiselect v-if="isLimitedToGroups(app)"
class="app-icon" /> :options="groups"
</svg> :value="appGroups"
{{ app.name }} :options-limit="5"
</h2> :placeholder="t('settings', 'Limit app usage to groups')"
<img v-if="screenshotLoaded" :src="app.screenshot" width="100%"> label="name"
<div v-if="app.level === 300 || app.level === 200 || hasRating" class="app-level"> track-by="id"
<span v-if="app.level === 300" class="multiselect-vue"
v-tooltip.auto="t('settings', 'This app is supported via your current Nextcloud subscription.')" :multiple="true"
class="supported icon-checkmark-color"> :close-on-select="false"
{{ t('settings', 'Supported') }}</span> :tag-width="60"
<span v-if="app.level === 200" @select="addGroupLimitation"
v-tooltip.auto="t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.')" @remove="removeGroupLimitation"
class="official icon-checkmark"> @search-change="asyncFindGroup">
{{ t('settings', 'Featured') }}</span> <span slot="noResult">{{ t('settings', 'No results') }}</span>
<AppScore v-if="hasRating" :score="app.appstoreData.ratingOverall" /> </Multiselect>
</div> </div>
<div class="app-details__actions-manage">
<div v-if="author" class="app-author">
{{ t('settings', 'by') }}
<span v-for="(a, index) in author" :key="index">
<a v-if="a['@attributes'] && a['@attributes']['homepage']" :href="a['@attributes']['homepage']">{{ a['@value'] }}</a><span v-else-if="a['@value']">{{ a['@value'] }}</span><span v-else>{{ a }}</span><span v-if="index+1 < author.length">, </span>
</span>
</div>
<div v-if="licence" class="app-licence">
{{ licence }}
</div>
<div class="actions">
<div class="actions-buttons">
<input v-if="app.update" <input v-if="app.update"
class="update primary" class="update primary"
type="button" type="button"
:value="t('settings', 'Update to {version}', {version: app.update})" :value="t('settings', 'Update to {version}', { version: app.update })"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click="update(app.id)"> @click="update(app.id)">
<input v-if="app.canUnInstall" <input v-if="app.canUnInstall"
class="uninstall" class="uninstall"
type="button" type="button"
:value="t('settings', 'Remove')" :value="t('settings', 'Remove')"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click="remove(app.id)"> @click="remove(app.id)">
<input v-if="app.active" <input v-if="app.active"
class="enable" class="enable"
type="button" type="button"
:value="t('settings','Disable')" :value="t('settings','Disable')"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click="disable(app.id)"> @click="disable(app.id)">
<input v-if="!app.active && (app.canInstall || app.isCompatible)" <input v-if="!app.active && (app.canInstall || app.isCompatible)"
v-tooltip.auto="enableButtonTooltip" v-tooltip.auto="enableButtonTooltip"
class="enable primary" class="enable primary"
type="button" type="button"
:value="enableButtonText" :value="enableButtonText"
:disabled="!app.canInstall || installing || loading(app.id)" :disabled="!app.canInstall || installing || isLoading"
@click="enable(app.id)"> @click="enable(app.id)">
<input v-else-if="!app.active" <input v-else-if="!app.active && !app.canInstall"
v-tooltip.auto="forceEnableButtonTooltip" v-tooltip.auto="forceEnableButtonTooltip"
class="enable force" class="enable force"
type="button" type="button"
:value="forceEnableButtonText" :value="forceEnableButtonText"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click="forceEnable(app.id)"> @click="forceEnable(app.id)">
</div> </div>
<div class="app-groups">
<div v-if="app.active && canLimitToGroups(app)" class="groups-enable">
<input :id="prefix('groups_enable', app.id)"
v-model="groupCheckedAppsData"
type="checkbox"
:value="app.id"
class="groups-enable__checkbox checkbox"
@change="setGroupLimit">
<label :for="prefix('groups_enable', app.id)">{{ t('settings', 'Limit to groups') }}</label>
<input type="hidden"
class="group_select"
:title="t('settings', 'All')"
value="">
<Multiselect v-if="isLimitedToGroups(app)"
:options="groups"
:value="appGroups"
:options-limit="5"
:placeholder="t('settings', 'Limit app usage to groups')"
label="name"
track-by="id"
class="multiselect-vue"
:multiple="true"
:close-on-select="false"
:tag-width="60"
@select="addGroupLimitation"
@remove="removeGroupLimitation"
@search-change="asyncFindGroup">
<span slot="noResult">{{ t('settings', 'No results') }}</span>
</Multiselect>
</div>
</div>
</div> </div>
<ul class="app-dependencies"> <ul class="app-details__dependencies">
<li v-if="app.missingMinOwnCloudVersion"> <li v-if="app.missingMinOwnCloudVersion">
{{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }} {{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }}
</li> </li>
@ -147,7 +105,7 @@
</li> </li>
</ul> </ul>
<p class="documentation"> <p class="app-details__documentation">
<a v-if="!app.internal" <a v-if="!app.internal"
class="appslink" class="appslink"
:href="appstoreUrl" :href="appstoreUrl"
@ -182,7 +140,7 @@
rel="noreferrer noopener">{{ t('settings', 'Developer documentation') }} </a> rel="noreferrer noopener">{{ t('settings', 'Developer documentation') }} </a>
</p> </p>
<div class="app-description" v-html="renderMarkdown" /> <div class="app-details__description" v-html="renderMarkdown" />
</div> </div>
</template> </template>
@ -191,25 +149,30 @@ import { Multiselect } from '@nextcloud/vue'
import marked from 'marked' import marked from 'marked'
import dompurify from 'dompurify' import dompurify from 'dompurify'
import AppScore from './AppList/AppScore' import AppManagement from '../mixins/AppManagement'
import AppManagement from './AppManagement'
import PrefixMixin from './PrefixMixin' import PrefixMixin from './PrefixMixin'
import SvgFilterMixin from './SvgFilterMixin'
export default { export default {
name: 'AppDetails', name: 'AppDetails',
components: { components: {
Multiselect, Multiselect,
AppScore,
}, },
mixins: [AppManagement, PrefixMixin, SvgFilterMixin], mixins: [AppManagement, PrefixMixin],
props: ['category', 'app'],
props: {
app: {
type: Object,
required: true,
},
},
data() { data() {
return { return {
groupCheckedAppsData: false, groupCheckedAppsData: false,
screenshotLoaded: false,
} }
}, },
computed: { computed: {
appstoreUrl() { appstoreUrl() {
return `https://apps.nextcloud.com/apps/${this.app.id}` return `https://apps.nextcloud.com/apps/${this.app.id}`
@ -220,9 +183,6 @@ export default {
} }
return null return null
}, },
hasRating() {
return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5
},
author() { author() {
if (typeof this.app.author === 'string') { if (typeof this.app.author === 'string') {
return [ return [
@ -309,27 +269,49 @@ export default {
if (this.app.groups.length > 0) { if (this.app.groups.length > 0) {
this.groupCheckedAppsData = true this.groupCheckedAppsData = true
} }
if (this.app.screenshot) {
const image = new Image()
image.onload = (e) => {
this.screenshotLoaded = true
}
image.src = this.app.screenshot
}
}, },
} }
</script> </script>
<style scoped> <style scoped lang="scss">
.force { .app-details {
background: var(--color-main-background); padding: 20px;
border-color: var(--color-error);
color: var(--color-error); &__actions {
// app management
&-manage {
// if too many, shrink them and ellipsis
display: flex;
input {
flex: 0 1 auto;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
} }
.force:hover, &__dependencies {
.force:active { opacity: .7;
background: var(--color-error);
border-color: var(--color-error) !important;
color: var(--color-main-background);
} }
&__documentation {
padding-top: 20px;
}
&__description {
padding-top: 20px;
}
}
.force {
color: var(--color-error);
border-color: var(--color-error);
background: var(--color-main-background);
}
.force:hover,
.force:active {
color: var(--color-main-background);
border-color: var(--color-error) !important;
background: var(--color-error);
}
</style> </style>

View File

@ -69,38 +69,38 @@
<div v-if="app.error" class="warning"> <div v-if="app.error" class="warning">
{{ app.error }} {{ app.error }}
</div> </div>
<div v-if="loading(app.id)" class="icon icon-loading-small" /> <div v-if="isLoading" class="icon icon-loading-small" />
<input v-if="app.update" <input v-if="app.update"
class="update primary" class="update primary"
type="button" type="button"
:value="t('settings', 'Update to {update}', {update:app.update})" :value="t('settings', 'Update to {update}', {update:app.update})"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click.stop="update(app.id)"> @click.stop="update(app.id)">
<input v-if="app.canUnInstall" <input v-if="app.canUnInstall"
class="uninstall" class="uninstall"
type="button" type="button"
:value="t('settings', 'Remove')" :value="t('settings', 'Remove')"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click.stop="remove(app.id)"> @click.stop="remove(app.id)">
<input v-if="app.active" <input v-if="app.active"
class="enable" class="enable"
type="button" type="button"
:value="t('settings','Disable')" :value="t('settings','Disable')"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click.stop="disable(app.id)"> @click.stop="disable(app.id)">
<input v-if="!app.active && (app.canInstall || app.isCompatible)" <input v-if="!app.active && (app.canInstall || app.isCompatible)"
v-tooltip.auto="enableButtonTooltip" v-tooltip.auto="enableButtonTooltip"
class="enable" class="enable"
type="button" type="button"
:value="enableButtonText" :value="enableButtonText"
:disabled="!app.canInstall || installing || loading(app.id)" :disabled="!app.canInstall || installing || isLoading"
@click.stop="enable(app.id)"> @click.stop="enable(app.id)">
<input v-else-if="!app.active" <input v-else-if="!app.active"
v-tooltip.auto="forceEnableButtonTooltip" v-tooltip.auto="forceEnableButtonTooltip"
class="enable force" class="enable force"
type="button" type="button"
:value="forceEnableButtonText" :value="forceEnableButtonText"
:disabled="installing || loading(app.id)" :disabled="installing || isLoading"
@click.stop="forceEnable(app.id)"> @click.stop="forceEnable(app.id)">
</div> </div>
</div> </div>
@ -108,7 +108,7 @@
<script> <script>
import AppScore from './AppScore' import AppScore from './AppScore'
import AppManagement from '../AppManagement' import AppManagement from '../../mixins/AppManagement'
import SvgFilterMixin from '../SvgFilterMixin' import SvgFilterMixin from '../SvgFilterMixin'
export default { export default {

View File

@ -1,40 +1,37 @@
<!-- /**
- @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- *
- @author Julius Härtl <jus@bitgrid.net> * @author Julius Härtl <jus@bitgrid.net>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- @license GNU AGPL version 3 or any later version *
- * @license GNU AGPL version 3 or any later version
- This program is free software: you can redistribute it and/or modify *
- it under the terms of the GNU Affero General Public License as * This program is free software: you can redistribute it and/or modify
- published by the Free Software Foundation, either version 3 of the * it under the terms of the GNU Affero General Public License as
- License, or (at your option) any later version. * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- This program is distributed in the hope that it will be useful, *
- but WITHOUT ANY WARRANTY; without even the implied warranty of * This program is distributed in the hope that it will be useful,
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * but WITHOUT ANY WARRANTY; without even the implied warranty of
- GNU Affero General Public License for more details. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- You should have received a copy of the GNU Affero General Public License *
- along with this program. If not, see <http://www.gnu.org/licenses/>. * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
--> *
*/
<script>
export default { export default {
computed: { computed: {
appGroups() { appGroups() {
return this.app.groups.map(group => { return { id: group, name: group } }) return this.app.groups.map(group => { return { id: group, name: group } })
}, },
loading() {
const self = this
return function(id) {
return self.$store.getters.loading(id)
}
},
installing() { installing() {
return this.$store.getters.loading('install') return this.$store.getters.loading('install')
}, },
isLoading() {
return this.app && this.$store.getters.loading(this.app.id)
},
enableButtonText() { enableButtonText() {
if (this.app.needsDownload) { if (this.app.needsDownload) {
return t('settings', 'Download and enable') return t('settings', 'Download and enable')
@ -61,11 +58,19 @@ export default {
return base return base
}, },
}, },
data() {
return {
groupCheckedAppsData: false,
}
},
mounted() { mounted() {
if (this.app.groups.length > 0) { if (this.app && this.app.groups && this.app.groups.length > 0) {
this.groupCheckedAppsData = true this.groupCheckedAppsData = true
} }
}, },
methods: { methods: {
asyncFindGroup(query) { asyncFindGroup(query) {
return this.$store.dispatch('getGroups', { search: query, limit: 5, offset: 0 }) return this.$store.dispatch('getGroups', { search: query, limit: 5, offset: 0 })
@ -135,4 +140,3 @@ export default {
}, },
}, },
} }
</script>

View File

@ -22,9 +22,10 @@
<template> <template>
<Content app-name="settings" <Content app-name="settings"
:class="{ 'with-app-sidebar': currentApp}" :class="{ 'with-app-sidebar': app}"
:content-class="{ 'icon-loading': loadingList }" :content-class="{ 'icon-loading': loadingList }"
:navigation-class="{ 'icon-loading': loading }"> :navigation-class="{ 'icon-loading': loading }">
<!-- Categories & filters -->
<AppNavigation> <AppNavigation>
<template #list> <template #list>
<AppNavigationItem <AppNavigationItem
@ -86,11 +87,39 @@
:title="t('settings', 'Developer documentation') + ' ↗'" /> :title="t('settings', 'Developer documentation') + ' ↗'" />
</template> </template>
</AppNavigation> </AppNavigation>
<!-- Apps list -->
<AppContent class="app-settings-content" :class="{ 'icon-loading': loadingList }"> <AppContent class="app-settings-content" :class="{ 'icon-loading': loadingList }">
<AppList :category="category" :app="currentApp" :search="searchQuery" /> <AppList :category="category" :app="app" :search="searchQuery" />
</AppContent> </AppContent>
<AppSidebar v-if="id && currentApp" @close="hideAppDetails">
<AppDetails :category="category" :app="currentApp" /> <!-- Selected app details -->
<AppSidebar
v-if="id && app"
v-bind="appSidebar"
:class="{'app-sidebar--without-background': !appSidebar.background}"
@close="hideAppDetails">
<template v-if="!appSidebar.background" #header>
<div class="app-sidebar-header__figure--default-app-icon icon-settings-dark" />
</template>
<template #primary-actions>
<!-- Featured/Supported badges -->
<div v-if="app.level === 300 || app.level === 200 || hasRating" class="app-level">
<span v-if="app.level === 300"
v-tooltip.auto="t('settings', 'This app is supported via your current Nextcloud subscription.')"
class="supported icon-checkmark-color">
{{ t('settings', 'Supported') }}</span>
<span v-if="app.level === 200"
v-tooltip.auto="t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.')"
class="official icon-checkmark">
{{ t('settings', 'Featured') }}</span>
<AppScore v-if="hasRating" :score="app.appstoreData.ratingOverall" />
</div>
</template>
<!-- Tab content -->
<AppDetails :app="app" />
</AppSidebar> </AppSidebar>
</Content> </Content>
</template> </template>
@ -108,11 +137,14 @@ import VueLocalStorage from 'vue-localstorage'
import AppList from '../components/AppList' import AppList from '../components/AppList'
import AppDetails from '../components/AppDetails' import AppDetails from '../components/AppDetails'
import AppManagement from '../mixins/AppManagement'
import AppScore from '../components/AppList/AppScore'
Vue.use(VueLocalStorage) Vue.use(VueLocalStorage)
export default { export default {
name: 'Apps', name: 'Apps',
components: { components: {
AppContent, AppContent,
AppDetails, AppDetails,
@ -121,9 +153,13 @@ export default {
AppNavigationCounter, AppNavigationCounter,
AppNavigationItem, AppNavigationItem,
AppNavigationSpacer, AppNavigationSpacer,
AppScore,
AppSidebar, AppSidebar,
Content, Content,
}, },
mixins: [AppManagement],
props: { props: {
category: { category: {
type: String, type: String,
@ -134,11 +170,14 @@ export default {
default: '', default: '',
}, },
}, },
data() { data() {
return { return {
searchQuery: '', searchQuery: '',
screenshotLoaded: false,
} }
}, },
computed: { computed: {
loading() { loading() {
return this.$store.getters.loading('categories') return this.$store.getters.loading('categories')
@ -146,7 +185,7 @@ export default {
loadingList() { loadingList() {
return this.$store.getters.loading('list') return this.$store.getters.loading('list')
}, },
currentApp() { app() {
return this.apps.find(app => app.id === this.id) return this.apps.find(app => app.id === this.id)
}, },
categories() { categories() {
@ -161,12 +200,53 @@ export default {
settings() { settings() {
return this.$store.getters.getServerData return this.$store.getters.getServerData
}, },
hasRating() {
return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5
},
// sidebar app binding
appSidebar() {
const author = Array.isArray(this.app.author)
? this.app.author[0]['@value']
? this.app.author.map(author => author['@value']).join(', ')
: this.app.author.join(', ')
: this.app.author['@value']
? this.app.author['@value']
: this.app.author
const license = t('settings', '{license}-licensed', { license: ('' + this.app.licence).toUpperCase() })
const subtitle = t('settings', 'by {author}\n{license}', { author, license })
return {
subtitle,
background: this.app.screenshot && this.screenshotLoaded
? this.app.screenshot
: this.app.preview,
compact: !(this.app.screenshot && this.screenshotLoaded),
title: this.app.name,
}
},
}, },
watch: { watch: {
category(val, old) { category(val, old) {
this.setSearch('') this.setSearch('')
}, },
app() {
this.screenshotLoaded = false
if (this.app && this.app.screenshot) {
const image = new Image()
image.onload = (e) => {
this.screenshotLoaded = true
}
image.src = this.app.screenshot
}
},
}, },
beforeMount() { beforeMount() {
this.$store.dispatch('getCategories') this.$store.dispatch('getCategories')
this.$store.dispatch('getAllApps') this.$store.dispatch('getAllApps')
@ -179,6 +259,7 @@ export default {
*/ */
this.appSearch = new OCA.Search(this.setSearch, this.resetSearch) this.appSearch = new OCA.Search(this.setSearch, this.resetSearch)
}, },
methods: { methods: {
setSearch(query) { setSearch(query) {
this.searchQuery = query this.searchQuery = query
@ -195,3 +276,54 @@ export default {
}, },
} }
</script> </script>
<style lang="scss" scoped>
.app-sidebar::v-deep {
&:not(.app-sidebar--without-background) {
// with full screenshot, let's fill the figure
:not(.app-sidebar-header--compact) .app-sidebar-header__figure {
background-size: cover;
}
// revert sidebar app icon so it is black
.app-sidebar-header--compact .app-sidebar-header__figure {
background-size: 32px;
filter: invert(1);
}
}
// default icon slot styling
&.app-sidebar--without-background {
.app-sidebar-header__figure {
display: flex;
align-items: center;
justify-content: center;
&--default-app-icon {
width: 32px;
height: 32px;
background-size: 32px;
}
}
}
// TODO: migrate to components
.app-sidebar-header__desc {
// allow multi line subtitle for the license
.app-sidebar-header__subtitle {
overflow: visible !important;
height: auto;
white-space: normal !important;
line-height: 16px;
}
}
.app-sidebar-header__action {
// align with tab content
margin: 0 20px;
input {
margin: 3px;
}
}
}
</style>