diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index e6cfa119c92cd..6e9cf8a22d944 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -19,13 +19,16 @@

{{ customName(app) }}

-

- {{ t('core', 'App download or installation failed') }} +

+ {{ app.error }} +

+

+ {{ t('core', 'App already installed') }}

{{ t('core', 'Cannot install this app because it is not compatible') }}

-

+

{{ t('core', 'Cannot install this app') }}

@@ -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') }} @@ -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: { @@ -95,6 +99,7 @@ const recommended = { }, richdocumentscode: { hidden: true, + required: ['richdocuments'], }, } const recommendedIds = Object.keys(recommended) @@ -106,6 +111,13 @@ export default { NcButton, }, + setup() { + return { + t, + canInstall, + } + }, + data() { return { showInstallButton: false, @@ -123,7 +135,7 @@ export default { }, isAnyAppSelected() { - return this.recommendedApps.some((app) => app.isSelected) + return this.recommendedApps.some((app) => app.isSelected && !app.active) }, }, @@ -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 @@ -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[]} */ + 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 } diff --git a/cypress/e2e/core/setup.ts b/cypress/e2e/core/setup.ts index 126c8058b83bc..90391dbf70192 100644 --- a/cypress/e2e/core/setup.ts +++ b/cypress/e2e/core/setup.ts @@ -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) @@ -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') } diff --git a/cypress/fixtures/appstore/apps.json b/cypress/fixtures/appstore/apps.json index db23c9a74eb38..e0b0a5ee5d1c9 100644 --- a/cypress/fixtures/appstore/apps.json +++ b/cypress/fixtures/appstore/apps.json @@ -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 - } - ] -} \ No newline at end of file + "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 + } + ] + } +}