Merge pull request #57555 from nextcloud/feat/office-switcher

feat: Add office switcher with feature comparison
This commit is contained in:
Julius Knorr 2026-02-03 20:40:01 +01:00 committed by GitHub
commit 8372533cff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 377 additions and 9 deletions

View file

@ -94,6 +94,7 @@ class AppSettingsController extends Controller {
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
$this->initialState->provideInitialState('isAllInOne', filter_var(getenv('THIS_IS_AIO'), FILTER_VALIDATE_BOOL));
$groups = array_map(static fn (IGroup $group): array => [
'id' => $group->getGID(),

View file

@ -5,6 +5,11 @@
<template>
<div id="app-content-inner">
<OfficeSuiteSwitcher
v-if="category === 'office'"
:installed-apps="allApps"
@suite-selected="onSuiteSelected" />
<div
id="apps-list"
class="apps-list"
@ -150,6 +155,8 @@ import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import pLimit from 'p-limit'
import NcButton from '@nextcloud/vue/components/NcButton'
import AppItem from './AppList/AppItem.vue'
import OfficeSuiteSwitcher from './AppList/OfficeSuiteSwitcher.vue'
import { getOfficeSuiteById, OFFICE_SUITES } from '../constants/OfficeSuites.js'
import logger from '../logger.ts'
import AppManagement from '../mixins/AppManagement.js'
import { useAppApiStore } from '../store/app-api-store.ts'
@ -160,6 +167,7 @@ export default {
components: {
AppItem,
NcButton,
OfficeSuiteSwitcher,
},
mixins: [AppManagement],
@ -207,6 +215,11 @@ export default {
return this.hasPendingUpdate && this.useListView
},
allApps() {
const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : []
return [...this.$store.getters.getAllApps, ...exApps]
},
apps() {
// Exclude ExApps from the list if AppAPI is disabled
const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : []
@ -308,7 +321,7 @@ export default {
},
},
beforeDestroy() {
beforeUnmount() {
unsubscribe('nextcloud:unified-search.search', this.setSearch)
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
},
@ -327,6 +340,40 @@ export default {
this.search = ''
},
async disableOfficeSuites(suites) {
const disablePromises = suites.map((suite) => this.$store.dispatch('disableApp', { appId: suite.appId }).catch(() => {}))
await Promise.all(disablePromises)
},
async onSuiteSelected(suiteId) {
logger.info('Office suite selected:', suiteId)
try {
if (suiteId === null) {
await this.disableOfficeSuites(OFFICE_SUITES)
OC.Notification.showTemporary(t('settings', 'All office suites disabled'))
return
}
const selectedSuite = getOfficeSuiteById(suiteId)
if (!selectedSuite) {
logger.error('Unknown office suite selected:', suiteId)
return
}
await this.$store.dispatch('enableApp', { appId: selectedSuite.appId, groups: [] })
OC.Notification.showTemporary(t('settings', '{name} enabled', { name: selectedSuite.name }))
const otherSuites = OFFICE_SUITES.filter((suite) => suite.id !== suiteId)
await this.disableOfficeSuites(otherSuites)
} catch (error) {
logger.error('Error switching office suite:', error)
if (error?.message) {
OC.Notification.showTemporary(error.message)
}
}
},
toggleBundle(id) {
if (this.allBundlesEnabled(id)) {
return this.disableBundle(id)

View file

@ -0,0 +1,265 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="office-suite-switcher">
<div v-if="isAllInOne" class="office-suite-switcher__aio-message">
<p>{{ t('settings', 'Office suite switching is managed through the Nextcloud All-in-One interface.') }}</p>
<p>{{ t('settings', 'Please use the AIO interface to switch between office suites.') }}</p>
</div>
<template v-else>
<p>{{ t('settings', 'Select your preferred office suite. Please note that installing requires manual server setup.') }}</p>
<div class="office-suite-cards">
<div
v-for="suite in officeSuites"
:key="suite.id"
class="office-suite-card"
:class="{
'office-suite-card--primary': suite.isPrimary,
'office-suite-card--selected': selectedSuite === suite.id,
}"
@click="selectSuite(suite.id)">
<div class="office-suite-card__header">
<h3 class="office-suite-card__title">
{{ suite.name }}
<span v-if="selectedSuite === suite.id">({{ t('settings', 'installed') }})</span>
</h3>
<IconCheckCircle v-if="selectedSuite === suite.id" class="office-suite-card__check" :size="24" />
</div>
<ul class="office-suite-card__features">
<li v-for="(feature, index) in suite.features" :key="index">
{{ t('settings', feature) }}
</li>
</ul>
<a
:href="suite.learnMoreUrl"
target="_blank"
rel="noopener noreferrer"
class="office-suite-card__link"
@click.stop>
{{ t('settings', 'Learn more') }}
<IconArrowRight :size="20" />
</a>
</div>
</div>
<div class="office-suite-actions">
<button
class="office-suite-disable-button"
:disabled="!selectedSuite"
@click="disableSuites">
{{ t('settings', 'Disable office suites') }}
</button>
</div>
</template>
</div>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconCheckCircle from 'vue-material-design-icons/CheckCircle.vue'
import { OFFICE_SUITES } from '../../constants/OfficeSuites.js'
export default {
name: 'OfficeSuiteSwitcher',
components: {
IconCheckCircle,
IconArrowRight,
},
props: {
installedApps: {
type: Array,
default: () => [],
},
},
emits: ['suite-selected'],
data() {
return {
isAllInOne: loadState('settings', 'isAllInOne', false),
selectedSuite: this.getInitialSuite(),
officeSuites: OFFICE_SUITES,
}
},
methods: {
t,
getInitialSuite() {
for (const suite of OFFICE_SUITES) {
const app = this.installedApps.find((a) => a.id === suite.appId)
if (app && app.active) {
return suite.id
}
}
return null
},
selectSuite(suiteId) {
if (this.selectedSuite === suiteId) {
// already selected keep selection; use the disable button to clear
return
}
this.selectedSuite = suiteId
this.$emit('suite-selected', suiteId)
},
disableSuites() {
if (this.selectedSuite === null) {
return
}
this.selectedSuite = null
this.$emit('suite-selected', null)
},
},
}
</script>
<style lang="scss" scoped>
.office-suite-switcher {
padding: 20px;
margin-bottom: 30px;
&__aio-message {
background-color: var(--color-background-dark);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
text-align: center;
}
p {
margin: 8px 0;
&:first-child {
font-weight: 600;
}
}
}
.office-suite-cards {
display: flex;
gap: 20px;
max-width: 1200px;
}
.office-suite-card {
flex: 1;
background-color: var(--color-main-background);
border: 2px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 24px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
& * {
cursor: pointer;
}
&:hover {
border-color: var(--color-primary-element);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&--selected {
background: linear-gradient(135deg, var(--color-primary-element-light) 0%, var(--color-main-background) 100%);
color: var(--color-main-text);
border-color: var(--color-primary-element);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
&__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.office-suite-card--primary &__check {
color: var(--color-primary-element);
}
&__features {
list-style: none;
padding: 0;
margin: 0 0 20px 0;
flex-grow: 1;
li {
padding: 4px 0;
padding-inline-start: 20px;
position: relative;
line-height: 1.5;
&::before {
content: '•';
position: absolute;
inset-inline-start: 0;
font-weight: bold;
}
}
}
&__link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--color-main-text);
text-decoration: none;
font-weight: 500;
margin-top: auto;
&:hover {
text-decoration: underline;
}
}
.office-suite-card--selected &__link {
color: var(--color-main-text);
}
}
.office-suite-actions {
margin-top: 16px;
}
.office-suite-disable-button {
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-small);
padding: 8px 12px;
font-weight: 600;
color: var(--color-main-text);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.office-suite-disable-button:disabled {
opacity: 0.5;
cursor: default;
}
.office-suite-disable-button:hover:not(:disabled) {
border-color: var(--color-primary-element);
background: var(--color-background-dark);
}
@media (max-width: 768px) {
.office-suite-cards {
flex-direction: column;
}
}
</style>

View file

@ -0,0 +1,56 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const OFFICE_SUITES = [
{
id: 'nextcloud-office',
appId: 'richdocuments',
name: 'Nextcloud Office',
features: [
t('settings', 'Best Nextcloud integration'),
t('settings', 'Open source'),
t('settings', 'Good performance'),
t('settings', 'Best security: documents never leave your server'),
t('settings', 'Best ODF compatibility'),
t('settings', 'Best support for legacy files'),
],
learnMoreUrl: 'https://nextcloud.com/collaboraonline/',
isPrimary: true,
},
{
id: 'onlyoffice',
appId: 'onlyoffice',
name: 'Onlyoffice',
features: [
t('settings', 'Good Nextcloud integration'),
t('settings', 'Open core'),
t('settings', 'Best performance'),
t('settings', 'Limited ODF compatibility'),
t('settings', 'Best Microsoft compatibility'),
],
learnMoreUrl: 'https://nextcloud.com/onlyoffice/',
isPrimary: false,
},
]
/**
* Get office suite configuration by ID
*
* @param {string} id - The suite ID
* @return {object|undefined} The suite configuration or undefined if not found
*/
export function getOfficeSuiteById(id) {
return OFFICE_SUITES.find((suite) => suite.id === id)
}
/**
* Get office suite configuration by app ID
*
* @param {string} appId - The app ID (richdocuments, onlyoffice, etc.)
* @return {object|undefined} The suite configuration or undefined if not found
*/
export function getOfficeSuiteByAppId(appId) {
return OFFICE_SUITES.find((suite) => suite.appId === appId)
}

View file

@ -99,7 +99,6 @@
</template>
</NcAppNavigationItem>
</template>
</template>
</NcAppNavigation>
</template>

View file

@ -35,7 +35,7 @@
<!-- Tab content -->
<AppDescriptionTab :app="app" />
<AppDetailsTab :app="app" :key="app.id" />
<AppDetailsTab :key="app.id" :app="app" />
<AppReleasesTab :app="app" />
<AppDeployDaemonTab :app="app" />
</NcAppSidebar>

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