Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 46 additions & 25 deletions core/src/components/setup/RecommendedApps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
<div class="info">
<h3>{{ customName(app) }}</h3>
<p v-text="customDescription(app.id)" />
<p v-if="app.installationError">
<strong>{{ t('core', 'App download or installation failed') }}</strong>
<p v-if="app.error">
<strong>{{ app.error }}</strong>
</p>
<p v-else-if="app.active">
<strong>{{ t('core', 'App already installed') }}</strong>
</p>
<p v-else-if="!app.isCompatible">
<strong>{{ t('core', 'Cannot install this app because it is not compatible') }}</strong>
</p>
<p v-else-if="!app.canInstall">
<p v-else-if="!canInstall(app)">
<strong>{{ t('core', 'Cannot install this app') }}</strong>
</p>
</div>
Expand All @@ -51,7 +54,7 @@
data-cy-setup-recommended-apps-install
:disabled="installingApps || !isAnyAppSelected"
variant="primary"
@click.stop.prevent="installApps">
@click="installApps">
{{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }}
</NcButton>
</div>
Expand All @@ -66,6 +69,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import logger from '../../logger.js'
import * as appstoreApi from '~/apps/appstore/src/service/api.ts'
import { canInstall } from '~/apps/appstore/src/utils/appStatus.js'
const recommended = {
calendar: {
Expand Down Expand Up @@ -95,6 +99,7 @@ const recommended = {
},
richdocumentscode: {
hidden: true,
required: ['richdocuments'],
},
}
const recommendedIds = Object.keys(recommended)
Expand All @@ -106,6 +111,13 @@ export default {
NcButton,
},
setup() {
return {
t,
canInstall,
}
},
data() {
return {
showInstallButton: false,
Expand All @@ -123,7 +135,7 @@ export default {
},
isAnyAppSelected() {
return this.recommendedApps.some((app) => app.isSelected)
return this.recommendedApps.some((app) => app.isSelected && !app.active)
},
},
Expand All @@ -132,7 +144,11 @@ export default {
const apps = await appstoreApi.getApps()
logger.info(`${apps.length} apps fetched`)
this.apps = apps.map((app) => Object.assign(app, { loading: false, installationError: false, isSelected: app.isCompatible }))
this.apps = apps.map((app) => Object.assign(app, {
loading: false,
installationError: false,
isSelected: app.isCompatible && !this.isHidden(app.id),
}))
this.$nextTick(() => logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps }))
this.showInstallButton = true
Expand All @@ -147,33 +163,38 @@ export default {
methods: {
async installApps() {
const apps = this.recommendedApps
.filter((app) => !app.active && app.isCompatible && app.canInstall && app.isSelected)
if (apps.length === 0) {
return
}
const availableApps = this.recommendedApps.filter((app) => app.active || (app.isSelected && canInstall(app)))
const appsToInstall = [
// all possible selected apps that are not active yet
...availableApps.filter((app) => !app.active && app.isSelected),
// all hidden apps that are required by the selected apps
...this.recommendedApps.filter((app) => this.isHidden(app.id)
&& recommended[app.id].required.every((requiredAppId) => availableApps.some((requiredApp) => requiredApp.id === requiredAppId))),
]
logger.debug(`Installing ${appsToInstall.length} recommended apps`, { appIds: appsToInstall.map((app) => app.id) })
this.installingApps = true
apps.forEach((app) => {
app.loading = true
})
const appIds = apps.map((app) => app.id)
logger.debug(`installing ${apps.length} recommended apps`, { appIds })
const promises = Promise.allSettled(appIds.map((appId) => appstoreApi.enableApp(appId)))
for (const app of apps) {
/** @type {Promise<void>[]} */
const promises = []
for (const app of appsToInstall) {
app.loading = true
promises.push(appstoreApi.enableApp(app.id))
}
const results = await promises
const results = await Promise.allSettled(promises)
for (let i = 0; i < results.length; i++) {
const result = results[i]
const app = apps[i]
const app = appsToInstall[i]
app.loading = false
if (result.status === 'rejected') {
logger.error(`could not install recommended app ${app.id}`, { error: result.reason })
app.loading = false
if (result.reason instanceof Error && result.reason.message === 'Dialog closed') {
logger.info(`User cancelled the password confirmation for recommended app ${app.id}`)
app.error = t('core', 'Password confirmation was aborted')
} else {
logger.error(`could not install recommended app ${app.id}`, { error: result.reason })
app.error = t('core', 'App download or installation failed')
}
app.isSelected = false
app.installationError = true
} else {
app.active = true
}
Expand Down
53 changes: 31 additions & 22 deletions cypress/e2e/core/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ describe('Can install Nextcloud', { testIsolation: true, retries: 0 }, () => {
sharedSetup()
})

it.skip('Sqlite - Install recommended apps (success)', () => {
it('Sqlite - Install recommended apps (success)', () => {
cy.visit('/')
cy.get('[data-cy-setup-form]').should('be.visible')
cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true })

sharedSetup('install-success')
})

it.skip('Sqlite - Install recommended apps (failure)', () => {
it('Sqlite - Install recommended apps (failure)', () => {
cy.visit('/')
cy.get('[data-cy-setup-form]').should('be.visible')
cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true })
Expand Down Expand Up @@ -138,8 +138,11 @@ describe('Can install Nextcloud', { testIsolation: true, retries: 0 }, () => {
function sharedSetup(mode: RecommendedAppsMode = 'skip') {
const randAdmin = 'admin-' + randomString(10)

// mock appstore
cy.intercept('**/settings/apps/list', { fixture: 'appstore/apps.json' })
// Mock the app store listing. The recommended-apps view fetches the apps via
// the appstore OCS API (`GET …/apps/appstore/api/v1/apps`), so the fixture
// must be OCS-shaped (`ocs.data`). Keep this in sync with the fixture, which
// currently exposes two recommended apps (calendar, contacts).
cy.intercept('GET', '**/apps/appstore/api/v1/apps', { fixture: 'appstore/apps.json' })

// Fill in the form
cy.get('[data-cy-setup-form-field="adminlogin"]').type(randAdmin)
Expand Down Expand Up @@ -174,30 +177,36 @@ function sharedSetup(mode: RecommendedAppsMode = 'skip') {
return
}

// Stub the bulk enable endpoint so we exercise the frontend flow without
// hitting the real app store.
cy.intercept('POST', '**/settings/apps/enable', mode === 'install-success'
? { statusCode: 200, body: { data: { update_required: false } } }
: { statusCode: 500, body: { data: { message: 'Forced failure' } } }).as('enableApps')
// The recommended apps are installed one after another, each via a single
// OCS enable request (`POST …/apps/appstore/api/v1/apps/enable`). Stub it so
// we exercise the frontend flow without hitting the real app store.
cy.intercept('POST', '**/apps/appstore/api/v1/apps/enable', mode === 'install-success'
? { statusCode: 200, body: { ocs: { meta: { status: 'ok', statuscode: 200, message: 'OK' }, data: { update_required: false } } } }
: { statusCode: 500, body: { ocs: { meta: { status: 'failure', statuscode: 500, message: 'Forced failure' }, data: [] } } }).as('enableApp')

cy.get('[data-cy-setup-recommended-apps-install]').click()

// The strict password-confirmation dialog must appear and must result in a
// Basic auth header on the enable request.
cy.findByRole('dialog', { name: 'Authentication required' })
.should('be.visible')
handlePasswordConfirmation(randAdmin)
cy.wait('@enableApps')
.its('request.headers.authorization')
.should('match', /^Basic /)
// Each app is enabled with a strict password confirmation, so one dialog is
// shown per app (there is no longer a single bulk request). Confirm every
// dialog and assert each enable request carries a Basic auth header.
// Keep RECOMMENDED_APP_COUNT in sync with the appstore/apps.json fixture.
const RECOMMENDED_APP_COUNT = 2
for (let i = 0; i < RECOMMENDED_APP_COUNT; i++) {
cy.findByRole('dialog', { name: 'Authentication required' })
.should('be.visible')
handlePasswordConfirmation(randAdmin)
cy.wait('@enableApp')
.its('request.headers.authorization')
.should('match', /^Basic /)
}

// The frontend no longer redirects after installing; it stays on the
// recommended-apps page and reflects the per-app result inline.
cy.location('pathname').should('include', '/core/apps/recommended')
if (mode === 'install-success') {
// Frontend redirects via window.location to the default page.
cy.location('pathname', { timeout: 10000 })
.should('not.include', '/core/apps/recommended')
cy.get('[data-cy-setup-recommended-apps]')
.should('contain.text', 'App already installed')
} else {
// Stay on the recommended-apps page and surface the per-app error state.
cy.location('pathname').should('include', '/core/apps/recommended')
cy.get('[data-cy-setup-recommended-apps]')
.should('contain.text', 'App download or installation failed')
}
Expand Down
69 changes: 25 additions & 44 deletions cypress/fixtures/appstore/apps.json
Original file line number Diff line number Diff line change
@@ -1,46 +1,27 @@
{
"apps": [
{
"id": "calendar",
"name": "Calendar",
"isCompatible": true,
"canInstall": true
"ocs": {
"meta": {
"status": "ok",
"statuscode": 200,
"message": "OK"
},
{
"id": "contacts",
"name": "Contacts",
"isCompatible": true,
"canInstall": true
},
{
"id": "mail",
"name": "Mail",
"isCompatible": true,
"canInstall": true
},
{
"id": "spreed",
"name": "Talk",
"isCompatible": true,
"canInstall": true
},
{
"id": "richdocuments",
"name": "Richdocuments",
"isCompatible": true,
"canInstall": true
},
{
"id": "notes",
"name": "Notes",
"isCompatible": true,
"canInstall": true
},
{
"id": "richdocumentscode",
"name": "Richdocuments Code",
"isCompatible": true,
"canInstall": true
}
]
}
"data": [
{
"id": "calendar",
"name": "Calendar",
"isCompatible": true,
"active": false,
"installed": false,
"internal": false
},
{
"id": "contacts",
"name": "Contacts",
"isCompatible": true,
"active": false,
"installed": false,
"internal": false
}
]
}
}
4 changes: 2 additions & 2 deletions dist/core-recommendedapps.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-recommendedapps.js.map

Large diffs are not rendered by default.

Loading