From 95e0640eb2561cc56dee8dc755c7672828d4caae Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 9 Aug 2022 12:41:39 +0200 Subject: [PATCH 01/27] MOBILE-4059 core: Translate hardcoded errors --- src/core/directives/external-content.ts | 3 ++- src/core/services/filepool.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/directives/external-content.ts b/src/core/directives/external-content.ts index 4663b44d7..c6a464c81 100644 --- a/src/core/directives/external-content.ts +++ b/src/core/directives/external-content.ts @@ -35,6 +35,7 @@ import { CoreSite } from '@classes/site'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreConstants } from '../constants'; import { CoreNetwork } from '@services/network'; +import { Translate } from '@singletons'; /** * Directive to handle external content. @@ -217,7 +218,7 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O if (!site.canDownloadFiles() && isSiteFile) { this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken. - throw new CoreError('Site doesn\'t allow downloading files.'); + throw new CoreError(Translate.instant('core.cannotdownloadfiles')); } const finalUrl = await this.getUrlToUse(targetAttr, url, site); diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 73c7dd48d..3c503deaf 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -374,7 +374,7 @@ export class CoreFilepoolProvider { const site = await CoreSites.getSite(siteId); if (!site.canDownloadFiles()) { - throw new CoreError('Site doesn\'t allow downloading files.'); + throw new CoreError(Translate.instant('core.cannotdownloadfiles')); } if (!alreadyFixed) { @@ -745,7 +745,7 @@ export class CoreFilepoolProvider { this.filePromises[siteId][downloadId] = CoreSites.getSite(siteId).then(async (site) => { if (!site.canDownloadFiles()) { - throw new CoreError('Site doesn\'t allow downloading files.'); + throw new CoreError(Translate.instant('core.cannotdownloadfiles')); } const entry = await CoreWS.downloadFile(fileUrl, path, addExtension, onProgress); From 4eb01a063ce05d33e85165e41f9162a440781b09 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 21 Sep 2022 11:12:53 +0200 Subject: [PATCH 02/27] MOBILE-4059 mainmenu: Add link to contact support --- scripts/langindex.json | 1 + src/core/classes/site.ts | 28 +++++ .../components/user-menu/user-menu.html | 8 ++ .../components/user-menu/user-menu.ts | 13 ++ src/core/features/user/lang.json | 1 + src/core/features/user/services/support.ts | 112 ++++++++++++++++++ .../user/tests/behat/support-311.feature | 13 ++ .../features/user/tests/behat/support.feature | 33 ++++++ src/core/services/utils/utils.ts | 6 +- src/core/singletons/events.ts | 9 ++ 10 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 src/core/features/user/services/support.ts create mode 100644 src/core/features/user/tests/behat/support-311.feature create mode 100644 src/core/features/user/tests/behat/support.feature diff --git a/scripts/langindex.json b/scripts/langindex.json index 7cd0b2a57..fc60c508f 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2363,6 +2363,7 @@ "core.user.roles": "moodle", "core.user.sendemail": "local_moodlemobileapp", "core.user.student": "moodle/defaultcoursestudent", + "core.user.support": "local_moodlemobileapp", "core.user.teacher": "moodle/noneditingteacher", "core.user.useraccount": "moodle", "core.user.userwithid": "local_moodlemobileapp", diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 0af78f156..0c8ef1484 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -60,6 +60,7 @@ import { import { Observable, ObservableInput, ObservedValueOf, OperatorFunction, Subject } from 'rxjs'; import { finalize, map, mergeMap } from 'rxjs/operators'; import { firstValueFrom } from '../utils/rxjs'; +import { CoreUserSupport } from '@features/user/services/support'; /** * QR Code type enumeration. @@ -262,6 +263,19 @@ export class CoreSite { return this.db; } + /** + * Get url to contact site support. + * + * @returns Site support page url. + */ + getSupportPageUrl(): string | null { + if (!this.config || !this.canContactSupport()) { + return null; + } + + return CoreUserSupport.getSupportPageUrl(this.config, this.siteUrl); + } + /** * Get site user's ID. * @@ -421,6 +435,19 @@ export class CoreSite { return !!(info && (info.usercanmanageownfiles === undefined || info.usercanmanageownfiles)); } + /** + * Check whether this site has a support url available. + * + * @returns Whether this site has a support url. + */ + canContactSupport(): boolean { + if (this.isFeatureDisabled('NoDelegate_CoreUserSupport')) { + return false; + } + + return !!this.config && CoreUserSupport.canContactSupport(this.config); + } + /** * Can the user download files? * @@ -2777,6 +2804,7 @@ export type CoreSitePublicConfigResponse = { agedigitalconsentverification?: boolean; // Whether age digital consent verification is enabled. supportname?: string; // Site support contact name (only if age verification is enabled). supportemail?: string; // Site support contact email (only if age verification is enabled). + supportpage?: string; // Site support contact url. autolang?: number; // Whether to detect default language from browser setting. lang?: string; // Default language for the site. langmenu?: number; // Whether the language menu should be displayed. diff --git a/src/core/features/mainmenu/components/user-menu/user-menu.html b/src/core/features/mainmenu/components/user-menu/user-menu.html index d6e138a39..1533ef68d 100644 --- a/src/core/features/mainmenu/components/user-menu/user-menu.html +++ b/src/core/features/mainmenu/components/user-menu/user-menu.html @@ -66,6 +66,14 @@

{{ 'core.settings.preferences' | translate }}

+ + + + +

{{ 'core.user.support' | translate }}

+
+
diff --git a/src/core/features/mainmenu/components/user-menu/user-menu.ts b/src/core/features/mainmenu/components/user-menu/user-menu.ts index 4b02fa22e..c58468c3d 100644 --- a/src/core/features/mainmenu/components/user-menu/user-menu.ts +++ b/src/core/features/mainmenu/components/user-menu/user-menu.ts @@ -18,6 +18,7 @@ import { CoreSite, CoreSiteInfo } from '@classes/site'; import { CoreFilter } from '@features/filter/services/filter'; import { CoreLoginSitesComponent } from '@features/login/components/sites/sites'; import { CoreLoginHelper } from '@features/login/services/login-helper'; +import { CoreUserSupport } from '@features/user/services/support'; import { CoreUser, CoreUserProfile } from '@features/user/services/user'; import { CoreUserProfileHandlerData, @@ -51,6 +52,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy { handlersLoaded = false; user?: CoreUserProfile; displaySwitchAccount = true; + displayContactSupport = false; removeAccountOnLogout = false; protected subscription!: Subscription; @@ -65,6 +67,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy { this.siteName = currentSite.getSiteName(); this.siteUrl = currentSite.getURL(); this.displaySwitchAccount = !currentSite.isFeatureDisabled('NoDelegate_SwitchAccount'); + this.displayContactSupport = currentSite.canContactSupport(); this.removeAccountOnLogout = !!CoreConstants.CONFIG.removeaccountonlogout; this.loadSiteLogo(currentSite); @@ -173,6 +176,16 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy { handler.action(event, this.user, CoreUserDelegateContext.USER_MENU); } + /** + * Contact site support. + * + * @param event Click event. + */ + async contactSupport(event: Event): Promise { + await this.close(event); + await CoreUserSupport.contact(); + } + /** * Logout the user. * diff --git a/src/core/features/user/lang.json b/src/core/features/user/lang.json index 147b26797..c2b42e2d1 100644 --- a/src/core/features/user/lang.json +++ b/src/core/features/user/lang.json @@ -28,6 +28,7 @@ "roles": "Roles", "sendemail": "Email", "student": "Student", + "support": "Support", "teacher": "Non-editing teacher", "userwithid": "User with ID {{id}}", "webpage": "Web page" diff --git a/src/core/features/user/services/support.ts b/src/core/features/user/services/support.ts new file mode 100644 index 000000000..2d59772ea --- /dev/null +++ b/src/core/features/user/services/support.ts @@ -0,0 +1,112 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreSiteConfig, CoreSitePublicConfigResponse } from '@classes/site'; +import { InAppBrowserObject } from '@ionic-native/in-app-browser'; +import { CorePlatform } from '@services/platform'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreSubscriptions } from '@singletons/subscriptions'; + +/** + * Handle site support. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserSupportService { + + /** + * Contact site support. + * + * @param options Options to configure the interaction with support. + */ + async contact(options: CoreUserSupportContactOptions = {}): Promise { + const supportPageUrl = options.supportPageUrl ?? CoreSites.getRequiredCurrentSite().getSupportPageUrl(); + + if (!supportPageUrl) { + throw new CoreError('Could not get support url'); + } + + const autoLoginUrl = await CoreSites.getCurrentSite()?.getAutoLoginUrl(supportPageUrl, false); + const browser = CoreUtils.openInApp(autoLoginUrl ?? supportPageUrl); + + if (supportPageUrl.endsWith('/user/contactsitesupport.php')) { + this.populateSupportForm(browser, options.subject, options.message); + } + + await CoreEvents.waitUntil(CoreEvents.IAB_EXIT); + } + + /** + * Get support page url from site config. + * + * @param config Site config. + * @returns Support page url. + */ + getSupportPageUrl(config: CoreSitePublicConfigResponse): string; + getSupportPageUrl(config: CoreSiteConfig, siteUrl: string): string; + getSupportPageUrl(config: CoreSiteConfig | CoreSitePublicConfigResponse, siteUrl?: string): string { + return config.supportpage?.trim() + || `${config.httpswwwroot ?? config.wwwroot ?? siteUrl}/user/contactsitesupport.php`; + } + + /** + * Check whether a site config allows contacting support. + * + * @param config Site config. + * @returns Whether site support can be contacted. + */ + canContactSupport(config: CoreSiteConfig | CoreSitePublicConfigResponse): boolean { + return 'supportpage' in config; + } + + /** + * Inject error details into contact support form. + * + * @param browser In App browser containing the support form. + * @param subject Title to fill into the form. + * @param message Details to fill into the form. + */ + protected populateSupportForm(browser: InAppBrowserObject, subject?: string | null, message?: string | null): void { + if (!CorePlatform.isMobile()) { + return; + } + + const unsubscribe = CoreSubscriptions.once(browser.on('loadstop'), () => { + browser.executeScript({ + code: ` + document.querySelector('#id_subject').value = ${JSON.stringify(subject ?? '')}; + document.querySelector('#id_message').value = ${JSON.stringify(message ?? '')}; + `, + }); + }); + + CoreEvents.once(CoreEvents.IAB_EXIT, () => unsubscribe()); + } + +} + +export const CoreUserSupport = makeSingleton(CoreUserSupportService); + +/** + * Options to configure interaction with support. + */ +export interface CoreUserSupportContactOptions { + supportPageUrl?: string | null; + subject?: string | null; + message?: string | null; +} diff --git a/src/core/features/user/tests/behat/support-311.feature b/src/core/features/user/tests/behat/support-311.feature new file mode 100644 index 000000000..17277e00f --- /dev/null +++ b/src/core/features/user/tests/behat/support-311.feature @@ -0,0 +1,13 @@ +@core @core_user @app @javascript @lms_upto3.11 +Feature: Site support + + Background: + Given the following "users" exist: + | username | firstname | lastname | + | student1 | Student | Student | + + Scenario: Cannot contact support + Given I entered the app as "student1" + When I press the user menu button in the app + Then I should find "Blog entries" in the app + But I should not find "Support" in the app diff --git a/src/core/features/user/tests/behat/support.feature b/src/core/features/user/tests/behat/support.feature new file mode 100644 index 000000000..5f6166135 --- /dev/null +++ b/src/core/features/user/tests/behat/support.feature @@ -0,0 +1,33 @@ +@core @core_user @app @javascript @lms_from4.0 +Feature: Site support + + Background: + Given the following "users" exist: + | username | firstname | lastname | + | student1 | Student | Student | + + Scenario: Uses default support page + Given I entered the app as "student1" + When I press the user menu button in the app + Then I should find "Support" in the app + + When I press "Support" in the app + Then the app should have opened a browser tab with url ".*\/user\/contactsitesupport\.php" + + Scenario: Uses custom support page + Given the following config values are set as admin: + | supportpage | https://campus.example.edu/support | + And I entered the app as "student1" + When I press the user menu button in the app + Then I should find "Support" in the app + + When I press "Support" in the app + Then the app should have opened a browser tab with url "https:\/\/campus\.example\.edu\/support" + + Scenario: Cannot contact support + Given the following config values are set as admin: + | disabledfeatures | NoDelegate_CoreUserSupport | tool_mobile | + And I entered the app as "student1" + When I press the user menu button in the app + Then I should find "Blog entries" in the app + But I should not find "Support" in the app diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 3ee795149..c241d3c2b 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1044,11 +1044,7 @@ export class CoreUtilsProvider { * @param options Override default options passed to InAppBrowser. * @return The opened window. */ - openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject | undefined { - if (!url) { - return; - } - + openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject { options = options || {}; options.usewkwebview = 'yes'; // Force WKWebView in iOS. options.enableViewPortScale = options.enableViewPortScale ?? 'yes'; // Enable zoom on iOS by default. diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 6d4d7213d..6dfdf5ea0 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -282,6 +282,15 @@ export class CoreEvents { } } + /** + * Wait until an event has been emitted. + * + * @param eventName Event name. + */ + static waitUntil(eventName: string): Promise { + return new Promise(resolve => this.once(eventName, () => resolve())); + } + } /** From 1a7d64a0d41cfe6ff4e030fada011ee987dcf67e Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Mon, 3 Oct 2022 17:07:48 +0200 Subject: [PATCH 03/27] MOBILE-4059 login: Contact support from errors Allow users to contact site support in errors where the site is not configured properly --- scripts/langindex.json | 1 + src/core/classes/errors/siteerror.ts | 46 +++++++++++++++++++--- src/core/features/login/pages/site/site.ts | 29 ++++++++++---- src/core/lang.json | 1 + src/core/services/sites.ts | 4 ++ 5 files changed, 68 insertions(+), 13 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index fc60c508f..fd18b45de 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1520,6 +1520,7 @@ "core.confirmloss": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", + "core.contactsupport": "local_moodlemobileapp", "core.content": "moodle", "core.contenteditingsynced": "local_moodlemobileapp", "core.contentlinks.chooseaccount": "local_moodlemobileapp", diff --git a/src/core/classes/errors/siteerror.ts b/src/core/classes/errors/siteerror.ts index a459a0257..57ab96d42 100644 --- a/src/core/classes/errors/siteerror.ts +++ b/src/core/classes/errors/siteerror.ts @@ -13,6 +13,8 @@ // limitations under the License. import { CoreError } from '@classes/errors/error'; +import { CoreSitePublicConfigResponse } from '@classes/site'; +import { CoreUserSupport } from '@features/user/services/support'; /** * Error returned when performing operations regarding a site (check if it exists, authenticate user, etc.). @@ -22,20 +24,52 @@ export class CoreSiteError extends CoreError { errorcode?: string; critical?: boolean; loggedOut?: boolean; + contactSupport?: boolean; + siteConfig?: CoreSitePublicConfigResponse; - constructor(protected error: SiteError) { - super(error.message); + constructor(options: CoreSiteErrorOptions) { + super(options.message); - this.errorcode = error.errorcode; - this.critical = error.critical; - this.loggedOut = error.loggedOut; + this.errorcode = options.errorcode; + this.critical = options.critical; + this.loggedOut = options.loggedOut; + this.contactSupport = options.contactSupport; + this.siteConfig = options.siteConfig; + } + + /** + * Get a url to contact site support. + * + * @returns Support page url. + */ + getSupportPageUrl(): string { + if (!this.siteConfig) { + throw new CoreError('Can\'t get support page url'); + } + + return CoreUserSupport.getSupportPageUrl(this.siteConfig); + } + + /** + * Check whether the handling of this error allows users to contact support or not. + * + * @returns Whether to contact support or not. + */ + canContactSupport(): boolean { + if (!this.contactSupport || !this.siteConfig) { + return false; + } + + return CoreUserSupport.canContactSupport(this.siteConfig); } } -export type SiteError = { +export type CoreSiteErrorOptions = { message: string; errorcode?: string; critical?: boolean; // Whether the error is important enough to abort the operation. loggedOut?: boolean; // Whether site has been marked as logged out. + contactSupport?: boolean; + siteConfig?: CoreSitePublicConfigResponse; }; diff --git a/src/core/features/login/pages/site/site.ts b/src/core/features/login/pages/site/site.ts index 9d900b2f2..5d26319d4 100644 --- a/src/core/features/login/pages/site/site.ts +++ b/src/core/features/login/pages/site/site.ts @@ -40,6 +40,8 @@ import { CoreCustomURLSchemes, CoreCustomURLSchemesHandleError } from '@services import { CoreTextUtils } from '@services/utils/text'; import { CoreForms } from '@singletons/form'; import { AlertButton } from '@ionic/core'; +import { CoreSiteError } from '@classes/errors/siteerror'; +import { CoreUserSupport } from '@features/user/services/support'; /** * Site (url) chooser when adding a new site. @@ -382,6 +384,13 @@ export class CoreLoginSitePage implements OnInit { */ protected showLoginIssue(url: string | null, error: CoreError): void { let errorMessage = CoreDomUtils.getErrorMessage(error); + let siteExists = false; + let supportPageUrl: string | null = null; + + if (error instanceof CoreSiteError) { + siteExists = !!error.siteConfig; + supportPageUrl = error.canContactSupport() ? error.getSupportPageUrl() : null; + } if (errorMessage == Translate.instant('core.cannotconnecttrouble')) { const found = this.sites.find((site) => site.url == url); @@ -392,19 +401,25 @@ export class CoreLoginSitePage implements OnInit { } let message = '

' + errorMessage + '

'; - if (url) { + if (!siteExists && url) { const fullUrl = CoreUrlUtils.isAbsoluteURL(url) ? url : 'https://' + url; message += '

' + url + '

'; } const buttons: AlertButton[] = [ - { - text: Translate.instant('core.needhelp'), - cssClass: 'core-login-need-help', - handler: (): void => { - this.showHelp(); + supportPageUrl + ? { + text: Translate.instant('core.contactsupport'), + handler: () => CoreUserSupport.contact({ + supportPageUrl, + subject: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + }), + } + : { + text: Translate.instant('core.needhelp'), + cssClass: 'core-login-need-help', + handler: () => this.showHelp(), }, - }, { text: Translate.instant('core.tryagain'), role: 'cancel', diff --git a/src/core/lang.json b/src/core/lang.json index 4a6262737..94aff25d5 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -54,6 +54,7 @@ "confirmloss": "Are you sure? All changes will be lost.", "confirmopeninbrowser": "Do you want to open it in a web browser?", "considereddigitalminor": "You are too young to create an account on this site.", + "contactsupport": "Contact support", "content": "Content", "contenteditingsynced": "The content you are editing has been synced.", "continue": "Continue", diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index c6d56dffd..d9dd0a655 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -299,11 +299,15 @@ export class CoreSitesProvider { throw new CoreSiteError({ message: Translate.instant('core.login.webservicesnotenabled'), critical: true, + contactSupport: true, + siteConfig: config, }); } else if (!config.enablemobilewebservice) { throw new CoreSiteError({ message: Translate.instant('core.login.mobileservicesnotenabled'), critical: true, + contactSupport: true, + siteConfig: config, }); } else if (config.maintenanceenabled) { let message = Translate.instant('core.sitemaintenance'); From 238dc458fcccf5d2a2033560f42cb0319a580b15 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Mon, 3 Oct 2022 17:36:52 +0200 Subject: [PATCH 04/27] MOBILE-4059 login: Add error details for support --- src/core/classes/errors/siteerror.ts | 3 ++ src/core/features/login/pages/site/site.ts | 5 +++ src/core/services/sites.ts | 45 ++++++++++++++++------ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/core/classes/errors/siteerror.ts b/src/core/classes/errors/siteerror.ts index 57ab96d42..6044e78c1 100644 --- a/src/core/classes/errors/siteerror.ts +++ b/src/core/classes/errors/siteerror.ts @@ -22,6 +22,7 @@ import { CoreUserSupport } from '@features/user/services/support'; export class CoreSiteError extends CoreError { errorcode?: string; + errorDetails?: string; critical?: boolean; loggedOut?: boolean; contactSupport?: boolean; @@ -31,6 +32,7 @@ export class CoreSiteError extends CoreError { super(options.message); this.errorcode = options.errorcode; + this.errorDetails = options.errorDetails; this.critical = options.critical; this.loggedOut = options.loggedOut; this.contactSupport = options.contactSupport; @@ -68,6 +70,7 @@ export class CoreSiteError extends CoreError { export type CoreSiteErrorOptions = { message: string; errorcode?: string; + errorDetails?: string; critical?: boolean; // Whether the error is important enough to abort the operation. loggedOut?: boolean; // Whether site has been marked as logged out. contactSupport?: boolean; diff --git a/src/core/features/login/pages/site/site.ts b/src/core/features/login/pages/site/site.ts index 5d26319d4..789cab1d6 100644 --- a/src/core/features/login/pages/site/site.ts +++ b/src/core/features/login/pages/site/site.ts @@ -386,10 +386,14 @@ export class CoreLoginSitePage implements OnInit { let errorMessage = CoreDomUtils.getErrorMessage(error); let siteExists = false; let supportPageUrl: string | null = null; + let errorDetails: string | undefined; + let errorCode: string | undefined; if (error instanceof CoreSiteError) { siteExists = !!error.siteConfig; supportPageUrl = error.canContactSupport() ? error.getSupportPageUrl() : null; + errorDetails = error.errorDetails; + errorCode = error.errorcode; } if (errorMessage == Translate.instant('core.cannotconnecttrouble')) { @@ -413,6 +417,7 @@ export class CoreLoginSitePage implements OnInit { handler: () => CoreUserSupport.contact({ supportPageUrl, subject: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + message: `Error: ${errorCode}\n\n${errorDetails}`, }), } : { diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index d9dd0a655..ad7cbc51c 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -296,19 +296,17 @@ export class CoreSitesProvider { // Check that the user can authenticate. if (!config.enablewebservices) { - throw new CoreSiteError({ - message: Translate.instant('core.login.webservicesnotenabled'), - critical: true, - contactSupport: true, - siteConfig: config, - }); + throw this.createCannotConnectError( + 'webservicesnotenabled', + Translate.instant('core.login.webservicesnotenabled'), + config, + ); } else if (!config.enablemobilewebservice) { - throw new CoreSiteError({ - message: Translate.instant('core.login.mobileservicesnotenabled'), - critical: true, - contactSupport: true, - siteConfig: config, - }); + throw this.createCannotConnectError( + 'mobileservicesnotenabled', + Translate.instant('core.login.mobileservicesnotenabled'), + config, + ); } else if (config.maintenanceenabled) { let message = Translate.instant('core.sitemaintenance'); if (config.maintenancemessage) { @@ -326,6 +324,29 @@ export class CoreSitesProvider { return { siteUrl, code: config?.typeoflogin || 0, service: CoreConstants.CONFIG.wsservice, config }; } + /** + * Create an error to be thrown when it isn't possible to connect to a site. + * + * @param errorcode Error code. + * @param errorDetails Error details. + * @param siteConfig Site config. + * @return Cannot connect error. + */ + protected createCannotConnectError( + errorcode: string, + errorDetails: string, + siteConfig: CoreSitePublicConfigResponse, + ): CoreSiteError { + return new CoreSiteError({ + errorcode, + errorDetails, + siteConfig, + message: Translate.instant('core.cannotconnecttrouble'), + critical: true, + contactSupport: true, + }); + } + /** * Treat an error returned by getPublicConfig in checkSiteWithProtocol. Converts the error to a CoreSiteError. * From 11fea266e9c544eeda40ad6cd1d7f55a4e418a37 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 4 Oct 2022 11:10:14 +0200 Subject: [PATCH 05/27] MOBILE-4059 core: Separate error details in alerts --- .storybook/main.js | 1 + .storybook/preview.js | 6 ++ .storybook/styles.scss | 3 + jest.config.js | 5 +- package-lock.json | 22 +++++ package.json | 2 + scripts/langindex.json | 4 + src/core/classes/errors/siteerror.ts | 20 +++- .../error-info/core-error-info.html | 6 ++ .../components/error-info/error-info.scss | 88 +++++++++++++++++ src/core/components/error-info/error-info.ts | 94 +++++++++++++++++++ .../components/stories/error-info.stories.ts | 50 ++++++++++ src/core/features/login/pages/site/site.ts | 30 ++++-- src/core/lang.json | 4 + src/core/services/sites.ts | 1 + src/core/singletons/form.ts | 14 +++ src/theme/theme.design-system.scss | 21 +++++ src/theme/theme.light.scss | 8 +- src/theme/theme.scss | 2 + src/types/raw.d.ts | 19 ++++ 20 files changed, 385 insertions(+), 15 deletions(-) create mode 100644 .storybook/preview.js create mode 100644 .storybook/styles.scss create mode 100644 src/core/components/error-info/core-error-info.html create mode 100644 src/core/components/error-info/error-info.scss create mode 100644 src/core/components/error-info/error-info.ts create mode 100644 src/core/components/stories/error-info.stories.ts create mode 100644 src/theme/theme.design-system.scss create mode 100644 src/types/raw.d.ts diff --git a/.storybook/main.js b/.storybook/main.js index fcf6c5b92..3443047d7 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,4 +1,5 @@ module.exports = { framework: '@storybook/angular', + addons: ['@storybook/addon-controls'], stories: ['../src/**/*.stories.ts'], } diff --git a/.storybook/preview.js b/.storybook/preview.js new file mode 100644 index 000000000..4db753bc8 --- /dev/null +++ b/.storybook/preview.js @@ -0,0 +1,6 @@ +import '!style-loader!css-loader!sass-loader!../src/theme/theme.design-system.scss'; +import '!style-loader!css-loader!sass-loader!./styles.scss'; + +export const parameters = { + layout: 'centered', +}; diff --git a/.storybook/styles.scss b/.storybook/styles.scss new file mode 100644 index 000000000..88ff7d26e --- /dev/null +++ b/.storybook/styles.scss @@ -0,0 +1,3 @@ +.core-error-info { + max-width: 300px; +} diff --git a/jest.config.js b/jest.config.js index f464ae96e..45708f586 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,7 +13,10 @@ module.exports = { '^.+\\.(ts|html)$': 'ts-jest', }, transformIgnorePatterns: ['node_modules/(?!@ionic-native|@ionic)'], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src/' }), + moduleNameMapper: { + ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src/' }), + '^!raw-loader!.*': 'jest-raw-loader', + }, globals: { 'ts-jest': { tsConfig: './tsconfig.test.json', diff --git a/package-lock.json b/package-lock.json index 8bf763956..5c60f7d24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5677,6 +5677,22 @@ "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz", "integrity": "sha512-/IubCWhVXCguyMUp/3zGrg3c882+RJNg/zpiKfyfJL3kRCOwe+/MD8OoAXVGdd+xAohZKIi1Ik+EHFlsptsjLg==" }, + "@storybook/addon-controls": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-6.1.21.tgz", + "integrity": "sha512-IJgZWD2E9eLKj8DJLA9lT63N4jPfVneFJ05gnPco01ZJCEiDAo7babP5Ns2UTJDUaQEtX0m04UoIkidcteWKsA==", + "dev": true, + "requires": { + "@storybook/addons": "6.1.21", + "@storybook/api": "6.1.21", + "@storybook/client-api": "6.1.21", + "@storybook/components": "6.1.21", + "@storybook/node-logger": "6.1.21", + "@storybook/theming": "6.1.21", + "core-js": "^3.0.1", + "ts-dedent": "^2.0.0" + } + }, "@storybook/addons": { "version": "6.1.21", "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.21.tgz", @@ -21473,6 +21489,12 @@ "ts-jest": "26.x" } }, + "jest-raw-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz", + "integrity": "sha512-g9oaAjeC4/rIJk1Wd3RxVbOfMizowM7LSjEJqa4R9qDX0OjQNABXOhH+GaznUp+DjTGVPi2vPPbQXyX87DOnYg==", + "dev": true + }, "jest-regex-util": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz", diff --git a/package.json b/package.json index 830c49903..768b8db14 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "@angular/language-service": "~10.0.14", "@ionic/angular-toolkit": "^2.3.3", "@ionic/cli": "^6.19.0", + "@storybook/addon-controls": "~6.1", "@storybook/angular": "~6.1", "@types/faker": "^5.1.3", "@types/node": "^12.12.64", @@ -172,6 +173,7 @@ "gulp-slash": "^1.1.3", "jest": "^26.5.2", "jest-preset-angular": "^8.3.1", + "jest-raw-loader": "^1.0.1", "jsonc-parser": "^2.3.1", "minimatch": "^5.1.0", "native-run": "^1.4.0", diff --git a/scripts/langindex.json b/scripts/langindex.json index fd18b45de..4ab49e21e 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1472,6 +1472,7 @@ "core.cancel": "moodle", "core.cannotconnect": "local_moodlemobileapp", "core.cannotconnecttrouble": "local_moodlemobileapp", + "core.cannotconnecttroublewithoutsupport": "local_moodlemobileapp", "core.cannotconnectverify": "local_moodlemobileapp", "core.cannotdownloadfiles": "local_moodlemobileapp", "core.cannotinstallapk": "local_moodlemobileapp", @@ -1697,7 +1698,10 @@ "core.endonesteptour": "tool_usertours", "core.error": "moodle", "core.errorchangecompletion": "local_moodlemobileapp", + "core.errorcode": "local_moodlemobileapp", "core.errordeletefile": "local_moodlemobileapp", + "core.errordetailshide": "local_moodlemobileapp", + "core.errordetailsshow": "local_moodlemobileapp", "core.errordownloading": "local_moodlemobileapp", "core.errordownloadingsomefiles": "local_moodlemobileapp", "core.errorfileexistssamename": "local_moodlemobileapp", diff --git a/src/core/classes/errors/siteerror.ts b/src/core/classes/errors/siteerror.ts index 6044e78c1..6858d3e6a 100644 --- a/src/core/classes/errors/siteerror.ts +++ b/src/core/classes/errors/siteerror.ts @@ -29,7 +29,7 @@ export class CoreSiteError extends CoreError { siteConfig?: CoreSitePublicConfigResponse; constructor(options: CoreSiteErrorOptions) { - super(options.message); + super(getErrorMessage(options)); this.errorcode = options.errorcode; this.errorDetails = options.errorDetails; @@ -67,8 +67,26 @@ export class CoreSiteError extends CoreError { } +/** + * Get message to use in the error. + * + * @param options Error options. + * @returns Error message. + */ +function getErrorMessage(options: CoreSiteErrorOptions): string { + if ( + options.contactSupport && + (!options.siteConfig || !CoreUserSupport.canContactSupport(options.siteConfig)) + ) { + return options.fallbackMessage ?? options.message; + } + + return options.message; +} + export type CoreSiteErrorOptions = { message: string; + fallbackMessage?: string; // Message to use if contacting support was intended but isn't possible. errorcode?: string; errorDetails?: string; critical?: boolean; // Whether the error is important enough to abort the operation. diff --git a/src/core/components/error-info/core-error-info.html b/src/core/components/error-info/core-error-info.html new file mode 100644 index 000000000..2808ecb68 --- /dev/null +++ b/src/core/components/error-info/core-error-info.html @@ -0,0 +1,6 @@ + diff --git a/src/core/components/error-info/error-info.scss b/src/core/components/error-info/error-info.scss new file mode 100644 index 000000000..784457cf1 --- /dev/null +++ b/src/core/components/error-info/error-info.scss @@ -0,0 +1,88 @@ +.core-error-info { + background: var(--gray-200); + border-radius: var(--small-radius); + font-size: var(--font-size-sm); + color: var(--gray-900); + + p:first-child { + margin-top: 0; + } + + p:last-child { + margin-bottom: 0; + } + + .core-error-info--content { + padding: var(--spacing-2) var(--spacing-2) 0 var(--spacing-2); + + .core-error-info--code { + font-size: var(--font-size-normal); + } + + .core-error-info--details { + color: var(--gray-500); + } + + } + + .core-error-info--checkbox { + display: none; + + & + .core-error-info--content { + max-height: calc(var(--font-size-sm) + 2 * var(--spacing-2)); + overflow: hidden; + transition: max-height 600ms ease-in-out; + + & + .core-error-info--toggle { + display: flex; + padding: var(--spacing-2); + min-height: var(--a11y-min-target-size); + align-items: center; + + span { + width: 100%; + display: flex; + justify-content: space-between; + } + + svg { + fill: currentColor; + width: 11px; + } + + .core-error-info--hide-content { + display: none; + } + + } + + } + + &:checked + .core-error-info--content { + max-height: 150px; + + & + .core-error-info--toggle .core-error-info--hide-content { + display: flex; + } + + & + .core-error-info--toggle .core-error-info--show-content { + display: none; + } + + } + + } + + &.has-error-code .core-error-info--checkbox { + + & + .core-error-info--content { + max-height: calc(var(--font-size-normal) + 2 * var(--spacing-2)); + } + + &:checked + .core-error-info--content { + max-height: 170px; + } + + } + +} diff --git a/src/core/components/error-info/error-info.ts b/src/core/components/error-info/error-info.ts new file mode 100644 index 000000000..6584653d9 --- /dev/null +++ b/src/core/components/error-info/error-info.ts @@ -0,0 +1,94 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ElementRef, Input, OnChanges, OnInit } from '@angular/core'; +import { Translate } from '@singletons'; +import { CoreForms } from '@singletons/form'; +import ChevronUpSVG from '!raw-loader!ionicons/dist/svg/chevron-up.svg'; +import ChevronDownSVG from '!raw-loader!ionicons/dist/svg/chevron-down.svg'; + +/** + * Component to show error details. + * + * Given that this component has to be injected dynamically in some situations (for example, error alerts), + * it can be rendered using the static render() method to get the raw HTML. + */ +@Component({ + selector: 'core-error-info', + templateUrl: 'core-error-info.html', + styleUrls: ['error-info.scss'], +}) +export class CoreErrorInfoComponent implements OnInit, OnChanges { + + /** + * Render an instance of the component into an HTML string. + * + * @param errorDetails Error details. + * @param errorCode Error code. + * @returns Component HTML. + */ + static render(errorDetails: string, errorCode?: string): string { + const toggleId = CoreForms.uniqueId('error-info-toggle'); + const errorCodeLabel = Translate.instant('core.errorcode'); + const hideDetailsLabel = Translate.instant('core.errordetailshide'); + const showDetailsLabel = Translate.instant('core.errordetailsshow'); + + return ` +
+ +
+ ${errorCode ? `

${errorCodeLabel}: ${errorCode}

` : ''} +

${errorDetails}

+
+ +
+ `; + } + + @Input() errorDetails!: string; + @Input() errorCode?: string; + + constructor(private element: ElementRef) {} + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.render(); + } + + /** + * @inheritdoc + */ + ngOnChanges(): void { + this.render(); + } + + /** + * Render component html in the element created by Angular. + */ + private render(): void { + this.element.nativeElement.innerHTML = CoreErrorInfoComponent.render(this.errorDetails, this.errorCode); + } + +} diff --git a/src/core/components/stories/error-info.stories.ts b/src/core/components/stories/error-info.stories.ts new file mode 100644 index 000000000..9c67a6d13 --- /dev/null +++ b/src/core/components/stories/error-info.stories.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Meta, moduleMetadata, Story } from '@storybook/angular'; + +import { story } from '@/storybook/utils/helpers'; +import { StorybookModule } from '@/storybook/storybook.module'; + +import { CoreErrorInfoComponent } from '@components/error-info/error-info'; + +interface Args { + errorCode: string; + errorDetails: string; +} + +export default > { + title: 'Core/Error Info', + component: CoreErrorInfoComponent, + decorators: [ + moduleMetadata({ + declarations: [CoreErrorInfoComponent], + imports: [StorybookModule], + }), + ], + args: { + errorCode: '', + errorDetails: + 'AJAX endpoint not found. ' + + 'This can happen if the Moodle site is too old or it blocks access to this endpoint. ' + + 'The Moodle app only supports Moodle systems 3.5 onwards.', + }, +}; + +const Template: Story = (args) => ({ + component: CoreErrorInfoComponent, + props: args, +}); + +export const Primary = story(Template); diff --git a/src/core/features/login/pages/site/site.ts b/src/core/features/login/pages/site/site.ts index 789cab1d6..b965e4bf6 100644 --- a/src/core/features/login/pages/site/site.ts +++ b/src/core/features/login/pages/site/site.ts @@ -42,6 +42,7 @@ import { CoreForms } from '@singletons/form'; import { AlertButton } from '@ionic/core'; import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreUserSupport } from '@features/user/services/support'; +import { CoreErrorInfoComponent } from '@components/error-info/error-info'; /** * Site (url) chooser when adding a new site. @@ -382,7 +383,7 @@ export class CoreLoginSitePage implements OnInit { * @param url The URL the user was trying to connect to. * @param error Error to display. */ - protected showLoginIssue(url: string | null, error: CoreError): void { + protected async showLoginIssue(url: string | null, error: CoreError): Promise { let errorMessage = CoreDomUtils.getErrorMessage(error); let siteExists = false; let supportPageUrl: string | null = null; @@ -396,7 +397,12 @@ export class CoreLoginSitePage implements OnInit { errorCode = error.errorcode; } - if (errorMessage == Translate.instant('core.cannotconnecttrouble')) { + if ( + !siteExists && ( + errorMessage === Translate.instant('core.cannotconnecttrouble') || + errorMessage === Translate.instant('core.cannotconnecttroublewithoutsupport') + ) + ) { const found = this.sites.find((site) => site.url == url); if (!found) { @@ -404,10 +410,14 @@ export class CoreLoginSitePage implements OnInit { } } - let message = '

' + errorMessage + '

'; + errorMessage = '

' + errorMessage + '

'; if (!siteExists && url) { const fullUrl = CoreUrlUtils.isAbsoluteURL(url) ? url : 'https://' + url; - message += '

' + url + '

'; + errorMessage += '

' + url + '

'; + } + + if (errorDetails) { + errorMessage += '
'; } const buttons: AlertButton[] = [ @@ -432,11 +442,19 @@ export class CoreLoginSitePage implements OnInit { ]; // @TODO: Remove CoreSite.MINIMUM_MOODLE_VERSION, not used on translations since 3.9.0. - CoreDomUtils.showAlertWithOptions({ + const alertElement = await CoreDomUtils.showAlertWithOptions({ header: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), - message, + message: errorMessage, buttons, }); + + if (errorDetails) { + const containerElement = alertElement.querySelector('.core-error-info-container'); + + if (containerElement) { + containerElement.innerHTML = CoreErrorInfoComponent.render(errorDetails, errorCode); + } + } } /** diff --git a/src/core/lang.json b/src/core/lang.json index 94aff25d5..746f7ef61 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -16,6 +16,7 @@ "cancel": "Cancel", "cannotconnect": "Cannot connect", "cannotconnecttrouble": "We're having trouble connecting to your site.", + "cannotconnecttroublewithoutsupport": "We're having trouble connecting to your site, please contact your institution.", "cannotconnectverify": "Please check the address is correct.", "cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", "cannotinstallapk": "For security reasons, you can't install unknown apps on your device from this app. Please open the file using a browser.", @@ -101,7 +102,10 @@ "endonesteptour": "Got it", "error": "Error", "errorchangecompletion": "An error occurred while changing the completion status. Please try again.", + "errorcode": "Error code", "errordeletefile": "Error deleting the file. Please try again.", + "errordetailshide": "Hide error details", + "errordetailsshow": "Show error details", "errordownloading": "Error downloading file.", "errordownloadingsomefiles": "Error downloading files. Some files might be missing.", "errorfileexistssamename": "A file with this name already exists.", diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index ad7cbc51c..f19d35895 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -342,6 +342,7 @@ export class CoreSitesProvider { errorDetails, siteConfig, message: Translate.instant('core.cannotconnecttrouble'), + fallbackMessage: Translate.instant('core.cannotconnecttroublewithoutsupport'), critical: true, contactSupport: true, }); diff --git a/src/core/singletons/form.ts b/src/core/singletons/form.ts index a40eb91f8..50d9e222e 100644 --- a/src/core/singletons/form.ts +++ b/src/core/singletons/form.ts @@ -20,6 +20,8 @@ import { CoreEventFormAction, CoreEvents } from '@singletons/events'; */ export class CoreForms { + private static formIds: Record = {}; + /** * Get the data from a form. It will only collect elements that have a name. * @@ -93,6 +95,18 @@ export class CoreForms { }, siteId); } + /** + * Generate a unique id for a form input using the given name. + * + * @param name Form input name. + * @returns Unique id. + */ + static uniqueId(name: string): string { + const count = this.formIds[name] ?? 0; + + return `${name}-${this.formIds[name] = count + 1}`; + } + } export type CoreFormFields = Record; diff --git a/src/theme/theme.design-system.scss b/src/theme/theme.design-system.scss new file mode 100644 index 000000000..7cdb11dd7 --- /dev/null +++ b/src/theme/theme.design-system.scss @@ -0,0 +1,21 @@ +html { + + // Spacing + @for $i from 0 to 13 { + --spacing-#{$i}: #{$i*4}px; + } + + // Font sizes + --font-size-sm: 12px; + --font-size-normal: 14px; + + // Radiuses + --small-radius: 4px; + --medium-radius: 8px; + --big-radius: 16px; + --huge-radius: 24px; + + // A11y + --a11y-min-target-size: 44px; + +} diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 76b786147..a18b8eec2 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -48,18 +48,12 @@ html { } // Accessibility vars. - --a11y-min-target-size: 44px; --a11y-focus-color: var(--primary); --a11y-focus-width: 2px; --zoom-level: 100%; - --small-radius: 4px; - --medium-radius: 8px; - --big-radius: 16px; - --huge-radius: 24px; - --text-color: #{$text-color}; - --text-size: 14px; + --text-size: var(--font-size-normal); --background-color: #{$background-color}; --stroke: var(--gray-300); diff --git a/src/theme/theme.scss b/src/theme/theme.scss index 83f8fc8d9..c13ffdfd5 100644 --- a/src/theme/theme.scss +++ b/src/theme/theme.scss @@ -16,6 +16,7 @@ @import "./theme.light.scss"; @import "./theme.dark.scss"; @import "./theme.custom.scss"; +@import "./theme.design-system.scss"; @import "./theme.base.scss"; /* Components */ @@ -24,6 +25,7 @@ @import "./components/format-text.scss"; @import "./components/rubrics.scss"; @import "./components/mod-label.scss"; +@import "../core/components/error-info/error-info.scss"; /* Some styles from 3rd party libraries. */ @import "./bootstrap.scss"; diff --git a/src/types/raw.d.ts b/src/types/raw.d.ts new file mode 100644 index 000000000..131f58329 --- /dev/null +++ b/src/types/raw.d.ts @@ -0,0 +1,19 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +declare module '!raw-loader!*' { + const contents: string; + + export = contents; +} From 608ea978a051260e348bf37c3f163af41c68955b Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 10 Aug 2022 14:42:11 +0200 Subject: [PATCH 06/27] MOBILE-4059 core: Fix timeout types --- src/addons/mod/h5pactivity/pages/index/index.ts | 4 ++-- src/core/directives/long-press.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/addons/mod/h5pactivity/pages/index/index.ts b/src/addons/mod/h5pactivity/pages/index/index.ts index 5ce40fbfb..0d7e0f15f 100644 --- a/src/addons/mod/h5pactivity/pages/index/index.ts +++ b/src/addons/mod/h5pactivity/pages/index/index.ts @@ -31,7 +31,7 @@ export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPa implements CanLeave, OnDestroy { canLeaveSafely = false; - remainingTimeout?: ReturnType; + remainingTimeout?: number; @ViewChild(AddonModH5PActivityIndexComponent) activityComponent?: AddonModH5PActivityIndexComponent; @@ -68,7 +68,7 @@ export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPa clearTimeout(this.remainingTimeout); } // When user finish an activity, he have 10 seconds to leave safely (without show alert). - this.remainingTimeout = setTimeout(() => { + this.remainingTimeout = window.setTimeout(() => { this.canLeaveSafely = false; }, 10000); } diff --git a/src/core/directives/long-press.ts b/src/core/directives/long-press.ts index 4d096eea9..836ac74bc 100644 --- a/src/core/directives/long-press.ts +++ b/src/core/directives/long-press.ts @@ -29,7 +29,7 @@ export class CoreLongPressDirective implements OnInit, OnDestroy { element: HTMLElement; pressGesture?: Gesture; - timeout?: NodeJS.Timeout; + timeout?: number; @Output() longPress = new EventEmitter(); @@ -48,7 +48,7 @@ export class CoreLongPressDirective implements OnInit, OnDestroy { disableScroll: true, gestureName: 'longpress', onStart: (event) => { - this.timeout = setTimeout(() => { + this.timeout = window.setTimeout(() => { this.longPress.emit(event); delete this.timeout; From 9f26620e0307eb8cfb7f1cd8a9dff3fce95d6a45 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 8 Sep 2022 15:34:42 +0200 Subject: [PATCH 07/27] MOBILE-4059 core: Improve CoreObject types Type helper extracted from https://stackoverflow.com/questions/57571664/typescript-type-for-an-object-with-only-one-key-no-union-type-allowed-as-a-key/57576688#57576688 --- src/core/singletons/object.ts | 17 +++++++++++------ src/core/utils/types.d.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 src/core/utils/types.d.ts diff --git a/src/core/singletons/object.ts b/src/core/singletons/object.ts index a26584d60..a3d2b9797 100644 --- a/src/core/singletons/object.ts +++ b/src/core/singletons/object.ts @@ -12,13 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -export type CoreObjectWithoutEmpty = { - [k in keyof T]: T[k] extends undefined | null ? never : T[k]; -}; +import { Pretty } from '@/core/utils/types'; -export type CoreObjectWithoutUndefined = { - [k in keyof T]: T[k] extends undefined ? never : T[k]; -}; +type ValueWithoutEmpty = T extends null | undefined ? never : T; +type ValueWithoutUndefined = T extends undefined ? never : T; + +export type CoreObjectWithoutEmpty = Pretty<{ + [k in keyof T]: ValueWithoutEmpty; +}>; + +export type CoreObjectWithoutUndefined = Pretty<{ + [k in keyof T]: ValueWithoutUndefined; +}>; /** * Singleton with helper functions for objects. diff --git a/src/core/utils/types.d.ts b/src/core/utils/types.d.ts new file mode 100644 index 000000000..f6b82e3e2 --- /dev/null +++ b/src/core/utils/types.d.ts @@ -0,0 +1,18 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Helper type to flatten complex types. + */ +export type Pretty = T extends infer U ? {[K in keyof U]: U[K]} : never; From 1005aad1c172ea66bc29e0979ee4d03089798447 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 8 Sep 2022 16:04:31 +0200 Subject: [PATCH 08/27] MOBILE-4059 core: Improve Translate.instant types --- .../comments/pages/viewer/viewer.page.ts | 2 +- src/core/features/course/services/sync.ts | 22 ++++++++++++------- src/core/services/utils/dom.ts | 8 +++++-- src/core/singletons/index.ts | 5 ++++- src/testing/utils.ts | 9 +++++--- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/core/features/comments/pages/viewer/viewer.page.ts b/src/core/features/comments/pages/viewer/viewer.page.ts index 4d7ddf9b7..54cf2285f 100644 --- a/src/core/features/comments/pages/viewer/viewer.page.ts +++ b/src/core/features/comments/pages/viewer/viewer.page.ts @@ -128,7 +128,7 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy { this.componentName = CoreNavigator.getRequiredRouteParam('componentName'); this.itemId = CoreNavigator.getRequiredRouteNumberParam('itemId'); this.area = CoreNavigator.getRouteParam('area') || ''; - this.title = CoreNavigator.getRouteNumberParam('title') || + this.title = CoreNavigator.getRouteParam('title') || Translate.instant('core.comments.comments'); this.courseId = CoreNavigator.getRouteNumberParam('courseId'); } catch (error) { diff --git a/src/core/features/course/services/sync.ts b/src/core/features/course/services/sync.ts index 773486202..5ef2145a4 100644 --- a/src/core/features/course/services/sync.ts +++ b/src/core/features/course/services/sync.ts @@ -193,10 +193,13 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { reject(); }, }, { - text: buttons && 'okText' in buttons ? buttons.okText : Translate.instant('core.ok'), + text: buttons && 'okText' in buttons + ? buttons.okText as string + : Translate.instant('core.ok'), handler: resolvePromise, }, ]; diff --git a/src/core/singletons/index.ts b/src/core/singletons/index.ts index faa44981f..ebaf9f791 100644 --- a/src/core/singletons/index.ts +++ b/src/core/singletons/index.ts @@ -217,7 +217,10 @@ export const Router = makeSingleton(RouterService); export const DomSanitizer = makeSingleton(DomSanitizerService); // Convert external libraries injectables. -export const Translate = makeSingleton(TranslateService); +export const Translate: Omit, 'instant'> & { + instant(keys: string[]): string[]; + instant(key: string, interpolateParams?: Record): string; +} = makeSingleton(TranslateService); // Async singletons. export const AngularFrameworkDelegate = asyncInstance(async () => { diff --git a/src/testing/utils.ts b/src/testing/utils.ts index 38b091cb8..b4bad0c31 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -29,6 +29,7 @@ import { CorePlatform } from '@services/platform'; import { CoreDB } from '@services/db'; import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; +import { TranslateService } from '@ngx-translate/core'; abstract class WrapperComponent { @@ -293,8 +294,10 @@ export function wait(time: number): Promise { * * @param translations List of translations. */ -export function mockTranslate(translations: Record): void { - mockSingleton(Translate, { - instant: (key) => translations[key] ?? key, +export function mockTranslate(translations: Record = {}): void { + mockSingleton(Translate as CoreSingletonProxy, { + instant: (key) => Array.isArray(key) + ? key.map(k => translations[k] ?? k) + : translations[key] ?? key, }); } From 4ca9a4ad7babd83162d9a2ebb5cb1407ac41d5d9 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 5 Oct 2022 11:27:15 +0200 Subject: [PATCH 09/27] MOBILE-4059 core: Extract inappbrowser initializer --- src/app/app.component.ts | 60 +------------ .../initializers/prepare-inapp-browser.ts | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 59 deletions(-) create mode 100644 src/core/initializers/prepare-inapp-browser.ts diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6897feeac..50afe4835 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,16 +19,14 @@ import { BackButtonEvent, ScrollDetail } from '@ionic/core'; import { CoreLang } from '@services/lang'; import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreEvents } from '@singletons/events'; -import { NgZone, SplashScreen, Translate } from '@singletons'; +import { NgZone, SplashScreen } from '@singletons'; import { CoreNetwork } from '@services/network'; import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; import { CoreNavigator } from '@services/navigator'; import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreWindow } from '@singletons/window'; -import { CoreCustomURLSchemes } from '@services/urlschemes'; import { CoreUtils } from '@services/utils/utils'; -import { CoreUrlUtils } from '@services/utils/url'; import { CoreConstants } from '@/core/constants'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; import { CoreDomUtils } from '@services/utils/dom'; @@ -46,8 +44,6 @@ export class AppComponent implements OnInit, AfterViewInit { @ViewChild(IonRouterOutlet) outlet?: IonRouterOutlet; - protected lastInAppUrl?: string; - /** * Component being initialized. */ @@ -91,60 +87,6 @@ export class AppComponent implements OnInit, AfterViewInit { content.classList.toggle('core-footer-shadow', !CoreDom.scrollIsBottom(scrollElement)); }); - // Check URLs loaded in any InAppBrowser. - CoreEvents.on(CoreEvents.IAB_LOAD_START, (event) => { - // URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this. - const protocol = CoreUrlUtils.getUrlProtocol(event.url); - const url = event.url.replace(/^https?:\/\//, ''); - const urlScheme = CoreUrlUtils.getUrlProtocol(url); - const isExternalApp = urlScheme && urlScheme !== 'file' && urlScheme !== 'cdvfile'; - - if (CoreCustomURLSchemes.isCustomURL(url)) { - // Close the browser if it's a valid SSO URL. - CoreCustomURLSchemes.handleCustomURL(url).catch((error) => { - CoreCustomURLSchemes.treatHandleCustomURLError(error); - }); - CoreUtils.closeInAppBrowser(); - - } else if (isExternalApp && url.includes('://token=')) { - // It's an SSO token for another app. Close the IAB and show an error. - CoreUtils.closeInAppBrowser(); - CoreDomUtils.showErrorModal(Translate.instant('core.login.contactyouradministratorissue', { - $a: '

' + Translate.instant('core.errorurlschemeinvalidscheme', { - $a: urlScheme, - }), - })); - - } else if (CoreApp.isAndroid()) { - // Check if the URL has a custom URL scheme. In Android they need to be opened manually. - if (isExternalApp) { - // Open in browser should launch the right app if found and do nothing if not found. - CoreUtils.openInBrowser(url, { showBrowserWarning: false }); - - // At this point the InAppBrowser is showing a "Webpage not available" error message. - // Try to navigate to last loaded URL so this error message isn't found. - if (this.lastInAppUrl) { - CoreUtils.openInApp(this.lastInAppUrl); - } else { - // No last URL loaded, close the InAppBrowser. - CoreUtils.closeInAppBrowser(); - } - } else { - this.lastInAppUrl = protocol ? `${protocol}://${url}` : url; - } - } - }); - - // Check InAppBrowser closed. - CoreEvents.on(CoreEvents.IAB_EXIT, () => { - this.lastInAppUrl = ''; - - if (CoreLoginHelper.isWaitingForBrowser()) { - CoreLoginHelper.stopWaitingForBrowser(); - CoreLoginHelper.checkLogout(); - } - }); - CorePlatform.resume.subscribe(() => { // Wait a second before setting it to false since in iOS there could be some frozen WS calls. setTimeout(() => { diff --git a/src/core/initializers/prepare-inapp-browser.ts b/src/core/initializers/prepare-inapp-browser.ts new file mode 100644 index 000000000..6c8593883 --- /dev/null +++ b/src/core/initializers/prepare-inapp-browser.ts @@ -0,0 +1,90 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreLoginHelper } from '@features/login/services/login-helper'; +import { CoreApp } from '@services/app'; +import { CoreCustomURLSchemes } from '@services/urlschemes'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; + +let lastInAppUrl: string | null = null; + +export default function(): void { + // Check URLs loaded in any InAppBrowser. + CoreEvents.on(CoreEvents.IAB_LOAD_START, async (event) => { + // URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this. + const protocol = CoreUrlUtils.getUrlProtocol(event.url); + const url = event.url.replace(/^https?:\/\//, ''); + const urlScheme = CoreUrlUtils.getUrlProtocol(url); + const isExternalApp = urlScheme && urlScheme !== 'file' && urlScheme !== 'cdvfile'; + + if (CoreCustomURLSchemes.isCustomURL(url)) { + // Close the browser if it's a valid SSO URL. + CoreCustomURLSchemes.handleCustomURL(url).catch((error) => { + CoreCustomURLSchemes.treatHandleCustomURLError(error); + }); + CoreUtils.closeInAppBrowser(); + + return; + } + + if (isExternalApp && url.includes('://token=')) { + // It's an SSO token for another app. Close the IAB and show an error. + CoreUtils.closeInAppBrowser(); + CoreDomUtils.showErrorModal(Translate.instant('core.login.contactyouradministratorissue', { + $a: '

' + Translate.instant('core.errorurlschemeinvalidscheme', { + $a: urlScheme, + }), + })); + + return; + } + + if (!CoreApp.isAndroid()) { + return; + } + + // Check if the URL has a custom URL scheme. In Android they need to be opened manually. + if (!isExternalApp) { + lastInAppUrl = protocol ? `${protocol}://${url}` : url; + + return; + } + + // Open in browser should launch the right app if found and do nothing if not found. + CoreUtils.openInBrowser(url, { showBrowserWarning: false }); + + // At this point the InAppBrowser is showing a "Webpage not available" error message. + // Try to navigate to last loaded URL so this error message isn't found. + if (lastInAppUrl) { + CoreUtils.openInApp(lastInAppUrl); + } else { + // No last URL loaded, close the InAppBrowser. + CoreUtils.closeInAppBrowser(); + } + }); + + // Check InAppBrowser closed. + CoreEvents.on(CoreEvents.IAB_EXIT, () => { + lastInAppUrl = null; + + if (CoreLoginHelper.isWaitingForBrowser()) { + CoreLoginHelper.stopWaitingForBrowser(); + CoreLoginHelper.checkLogout(); + } + }); +} From 0545d3a8c875150603c10ad5981d582860f9aadb Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 5 Oct 2022 11:37:50 +0200 Subject: [PATCH 10/27] MOBILE-4059 testing: Improve mockSingleton types --- src/core/classes/tests/database-table.test.ts | 2 +- src/core/directives/tests/format-text.test.ts | 14 +++++++++++--- src/core/services/tests/navigator.test.ts | 2 +- src/testing/utils.ts | 3 +-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/core/classes/tests/database-table.test.ts b/src/core/classes/tests/database-table.test.ts index 8e1e0f1e9..3b9c2bca2 100644 --- a/src/core/classes/tests/database-table.test.ts +++ b/src/core/classes/tests/database-table.test.ts @@ -63,7 +63,7 @@ function prepareStubs(config: Partial = {}): [User[], }); const table = new CoreDatabaseTableProxy(config, database, 'users'); - mockSingleton(CoreConfig, { isReady: () => Promise.resolve() }); + mockSingleton(CoreConfig, { ready: () => Promise.resolve() }); return [records, database, table]; } diff --git a/src/core/directives/tests/format-text.test.ts b/src/core/directives/tests/format-text.test.ts index 0face19cd..265cd218a 100644 --- a/src/core/directives/tests/format-text.test.ts +++ b/src/core/directives/tests/format-text.test.ts @@ -33,7 +33,15 @@ describe('CoreFormatTextDirective', () => { beforeEach(() => { mockSingleton(CoreSites, { getSite: () => Promise.reject() }); - mockSingleton(CoreConfig, { get: (_, defaultValue) => defaultValue }); + mockSingleton(CoreConfig, { + get(name, defaultValue) { + if (defaultValue === undefined) { + throw Error(`Default value not provided for '${name}'`); + } + + return Promise.resolve(defaultValue); + }, + }); mockSingleton(CoreFilter, { formatText: text => Promise.resolve(text) }); mockSingleton(CoreFilterHelper, { getFiltersAndFormatText: text => Promise.resolve({ text, filters: [] }) }); @@ -64,7 +72,7 @@ describe('CoreFormatTextDirective', () => { it('should format text', async () => { // Arrange - mockSingleton(CoreFilter, { formatText: () => 'Formatted text' }); + mockSingleton(CoreFilter, { formatText: () => Promise.resolve('Formatted text') }); // Act const { nativeElement } = await renderTemplate( @@ -131,7 +139,7 @@ describe('CoreFormatTextDirective', () => { mockSingleton(CoreFilepool, { getSrcByUrl: () => Promise.resolve('file://local-path') }); mockSingleton(CoreSites, { getSite: () => Promise.resolve(site), - getCurrentSite: () => Promise.resolve(site), + getCurrentSite: () => site, }); // Act diff --git a/src/core/services/tests/navigator.test.ts b/src/core/services/tests/navigator.test.ts index 0433ecb0f..ff6b9f409 100644 --- a/src/core/services/tests/navigator.test.ts +++ b/src/core/services/tests/navigator.test.ts @@ -40,7 +40,7 @@ describe('CoreNavigator', () => { router = mockSingleton(Router, { url: '/' }); - mockSingleton(CoreSites, { getCurrentSiteId: () => 42, isLoggedIn: () => true }); + mockSingleton(CoreSites, { getCurrentSiteId: () => '42', isLoggedIn: () => true }); mockSingleton(CoreMainMenu, { isMainMenuTab: path => Promise.resolve(currentMainMenuHandlers.includes(path)) }); }); diff --git a/src/testing/utils.ts b/src/testing/utils.ts index b4bad0c31..27e20a0b0 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -183,8 +183,7 @@ export function mock( return instance as T; } -export function mockSingleton(singletonClass: CoreSingletonProxy, instance: T): T; -export function mockSingleton(singletonClass: CoreSingletonProxy, instance?: Record): T; +export function mockSingleton(singletonClass: CoreSingletonProxy, instance: T | Partial): T; export function mockSingleton( singletonClass: CoreSingletonProxy, methods: string[], From 28fd894aab45b838c74ae89a0e28329a9e5919b5 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 5 Oct 2022 12:26:56 +0200 Subject: [PATCH 11/27] MOBILE-4059 login: Contact support in credentials When login attempts failed multiple times, suggest contacting site support --- scripts/langindex.json | 2 + src/core/components/tests/icon.test.ts | 4 +- src/core/components/tests/iframe.test.ts | 2 +- src/core/components/tests/user-avatar.test.ts | 2 +- src/core/directives/tests/format-text.test.ts | 10 +- src/core/directives/tests/link.test.ts | 4 +- .../update-non-reactive-attributes.ts | 8 +- .../login/components/components.module.ts | 3 + .../exceeded-attempts/exceeded-attempts.html | 13 ++ .../exceeded-attempts/exceeded-attempts.scss | 9 ++ .../exceeded-attempts/exceeded-attempts.ts | 53 ++++++++ src/core/features/login/lang.json | 2 + .../login/pages/credentials/credentials.html | 5 + .../pages/credentials/credentials.module.ts | 2 + .../login/pages/credentials/credentials.ts | 5 +- .../login/pages/reconnect/reconnect.html | 5 + .../login/pages/reconnect/reconnect.ts | 5 +- .../features/login/tests/credentials.test.ts | 81 ++++++++++++ src/testing/utils.ts | 121 ++++++++++++++++-- 19 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 src/core/features/login/components/exceeded-attempts/exceeded-attempts.html create mode 100644 src/core/features/login/components/exceeded-attempts/exceeded-attempts.scss create mode 100644 src/core/features/login/components/exceeded-attempts/exceeded-attempts.ts create mode 100644 src/core/features/login/tests/credentials.test.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index 4ab49e21e..565b5b0bf 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1932,6 +1932,8 @@ "core.login.errorexampleurl": "local_moodlemobileapp", "core.login.errorqrnoscheme": "local_moodlemobileapp", "core.login.errorupdatesite": "local_moodlemobileapp", + "core.login.exceededloginattempts": "local_moodlemobileapp", + "core.login.exceededloginattemptssupportsubject": "local_moodlemobileapp", "core.login.faqcannotconnectanswer": "local_moodlemobileapp", "core.login.faqcannotconnectquestion": "local_moodlemobileapp", "core.login.faqcannotfindmysiteanswer": "local_moodlemobileapp", diff --git a/src/core/components/tests/icon.test.ts b/src/core/components/tests/icon.test.ts index 0153b35cc..8efe2fcc6 100644 --- a/src/core/components/tests/icon.test.ts +++ b/src/core/components/tests/icon.test.ts @@ -26,11 +26,11 @@ describe('CoreIconComponent', () => { expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0); const icon = fixture.nativeElement.querySelector('ion-icon'); - const name = icon.getAttribute('name') || icon.getAttribute('ng-reflect-name') || ''; + const name = icon?.getAttribute('name') || icon?.getAttribute('ng-reflect-name') || ''; expect(icon).not.toBeNull(); expect(name).toEqual('fa-thumbs-up'); - expect(icon.getAttribute('role')).toEqual('presentation'); + expect(icon?.getAttribute('role')).toEqual('presentation'); }); }); diff --git a/src/core/components/tests/iframe.test.ts b/src/core/components/tests/iframe.test.ts index 75a0c754d..93106369d 100644 --- a/src/core/components/tests/iframe.test.ts +++ b/src/core/components/tests/iframe.test.ts @@ -33,7 +33,7 @@ describe('CoreIframeComponent', () => { const iframe = nativeElement.querySelector('iframe'); expect(iframe).not.toBeNull(); - expect(iframe.src).toEqual('https://moodle.org/'); + expect(iframe?.src).toEqual('https://moodle.org/'); }); }); diff --git a/src/core/components/tests/user-avatar.test.ts b/src/core/components/tests/user-avatar.test.ts index 7b4c5f12a..a8c085b74 100644 --- a/src/core/components/tests/user-avatar.test.ts +++ b/src/core/components/tests/user-avatar.test.ts @@ -27,7 +27,7 @@ describe('CoreUserAvatarComponent', () => { const image = nativeElement.querySelector('img'); expect(image).not.toBeNull(); - expect(image.src).toEqual(document.location.href + 'assets/img/user-avatar.png'); + expect(image?.src).toEqual(document.location.href + 'assets/img/user-avatar.png'); }); }); diff --git a/src/core/directives/tests/format-text.test.ts b/src/core/directives/tests/format-text.test.ts index 265cd218a..843e76742 100644 --- a/src/core/directives/tests/format-text.test.ts +++ b/src/core/directives/tests/format-text.test.ts @@ -67,7 +67,7 @@ describe('CoreFormatTextDirective', () => { // Assert const text = fixture.nativeElement.querySelector('core-format-text'); expect(text).not.toBeNull(); - expect(text.innerHTML).toEqual(sentence); + expect(text?.innerHTML).toEqual(sentence); }); it('should format text', async () => { @@ -83,7 +83,7 @@ describe('CoreFormatTextDirective', () => { // Assert const text = nativeElement.querySelector('core-format-text'); expect(text).not.toBeNull(); - expect(text.textContent).toEqual('Formatted text'); + expect(text?.textContent).toEqual('Formatted text'); expect(CoreFilter.formatText).toHaveBeenCalledTimes(1); expect(CoreFilter.formatText).toHaveBeenCalledWith( @@ -115,7 +115,7 @@ describe('CoreFormatTextDirective', () => { // Assert const text = nativeElement.querySelector('core-format-text'); expect(text).not.toBeNull(); - expect(text.textContent).toEqual('Formatted text'); + expect(text?.textContent).toEqual('Formatted text'); expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledTimes(1); expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledWith( @@ -153,7 +153,7 @@ describe('CoreFormatTextDirective', () => { // Assert const image = nativeElement.querySelector('img'); expect(image).not.toBeNull(); - expect(image.src).toEqual('file://local-path/'); + expect(image?.src).toEqual('file://local-path/'); expect(CoreSites.getSite).toHaveBeenCalledWith(site.getId()); expect(CoreFilepool.getSrcByUrl).toHaveBeenCalledTimes(1); @@ -171,7 +171,7 @@ describe('CoreFormatTextDirective', () => { ); const anchor = nativeElement.querySelector('a'); - anchor.click(); + anchor?.click(); // Assert expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1); diff --git a/src/core/directives/tests/link.test.ts b/src/core/directives/tests/link.test.ts index 891df6111..169713dce 100644 --- a/src/core/directives/tests/link.test.ts +++ b/src/core/directives/tests/link.test.ts @@ -31,7 +31,7 @@ describe('CoreLinkDirective', () => { const anchor = fixture.nativeElement.querySelector('a'); expect(anchor).not.toBeNull(); - expect(anchor.href).toEqual('https://moodle.org/'); + expect(anchor?.href).toEqual('https://moodle.org/'); }); it('should capture clicks', async () => { @@ -46,7 +46,7 @@ describe('CoreLinkDirective', () => { const anchor = nativeElement.querySelector('a'); - anchor.click(); + anchor?.click(); // Assert expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1); diff --git a/src/core/directives/update-non-reactive-attributes.ts b/src/core/directives/update-non-reactive-attributes.ts index 6089403a4..b8ce529d7 100644 --- a/src/core/directives/update-non-reactive-attributes.ts +++ b/src/core/directives/update-non-reactive-attributes.ts @@ -27,7 +27,7 @@ import { Directive, ElementRef, OnDestroy, OnInit } from '@angular/core'; }) export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestroy { - protected element: HTMLIonButtonElement; + protected element: HTMLIonButtonElement | HTMLElement; protected mutationObserver: MutationObserver; constructor(element: ElementRef) { @@ -52,7 +52,11 @@ export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestr * @inheritdoc */ async ngOnInit(): Promise { - await this.element.componentOnReady(); + if ('componentOnReady' in this.element) { + // This may be necessary if this is somehow called but Ionic's directives arent. This happens, for example, + // in some tests such as the credentials page. + await this.element.componentOnReady(); + } this.mutationObserver.observe(this.element, { attributes: true, attributeFilter: ['aria-label'] }); } diff --git a/src/core/features/login/components/components.module.ts b/src/core/features/login/components/components.module.ts index e2cdddf84..2ad4127b7 100644 --- a/src/core/features/login/components/components.module.ts +++ b/src/core/features/login/components/components.module.ts @@ -18,9 +18,11 @@ import { CoreLoginSiteOnboardingComponent } from './site-onboarding/site-onboard import { CoreLoginSiteHelpComponent } from './site-help/site-help'; import { CoreLoginSitesComponent } from './sites/sites'; import { CoreLoginMethodsComponent } from './login-methods/login-methods'; +import { CoreLoginExceededAttemptsComponent } from '@features/login/components/exceeded-attempts/exceeded-attempts'; @NgModule({ declarations: [ + CoreLoginExceededAttemptsComponent, CoreLoginSiteOnboardingComponent, CoreLoginSiteHelpComponent, CoreLoginSitesComponent, @@ -30,6 +32,7 @@ import { CoreLoginMethodsComponent } from './login-methods/login-methods'; CoreSharedModule, ], exports: [ + CoreLoginExceededAttemptsComponent, CoreLoginSiteOnboardingComponent, CoreLoginSiteHelpComponent, CoreLoginSitesComponent, diff --git a/src/core/features/login/components/exceeded-attempts/exceeded-attempts.html b/src/core/features/login/components/exceeded-attempts/exceeded-attempts.html new file mode 100644 index 000000000..aea9a4a2d --- /dev/null +++ b/src/core/features/login/components/exceeded-attempts/exceeded-attempts.html @@ -0,0 +1,13 @@ + + + + +

+ +

+ + {{ 'core.contactsupport' | translate }} + +
+
+
diff --git a/src/core/features/login/components/exceeded-attempts/exceeded-attempts.scss b/src/core/features/login/components/exceeded-attempts/exceeded-attempts.scss new file mode 100644 index 000000000..5fc9e13e4 --- /dev/null +++ b/src/core/features/login/components/exceeded-attempts/exceeded-attempts.scss @@ -0,0 +1,9 @@ +:host { + + ion-button { + margin-left: 0; + margin-right: 0; + margin-top: 16px; + } + +} diff --git a/src/core/features/login/components/exceeded-attempts/exceeded-attempts.ts b/src/core/features/login/components/exceeded-attempts/exceeded-attempts.ts new file mode 100644 index 000000000..f1be67bd9 --- /dev/null +++ b/src/core/features/login/components/exceeded-attempts/exceeded-attempts.ts @@ -0,0 +1,53 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { CoreSiteConfig } from '@classes/site'; +import { CoreUserSupport } from '@features/user/services/support'; + +@Component({ + selector: 'core-login-exceeded-attempts', + templateUrl: 'exceeded-attempts.html', + styleUrls: ['./exceeded-attempts.scss'], +}) +export class CoreLoginExceededAttemptsComponent implements OnInit { + + @Input() siteUrl!: string; + @Input() siteConfig!: CoreSiteConfig; + @Input() supportSubject?: string; + + canContactSupport = false; + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.canContactSupport = CoreUserSupport.canContactSupport(this.siteConfig); + } + + /** + * Contact site support. + */ + async contactSupport(): Promise { + if (!this.siteConfig) { + throw new Error('Can\'t contact support without config'); + } + + await CoreUserSupport.contact({ + supportPageUrl: CoreUserSupport.getSupportPageUrl(this.siteConfig, this.siteUrl), + subject: this.supportSubject, + }); + } + +} diff --git a/src/core/features/login/lang.json b/src/core/features/login/lang.json index 282dc4d93..a8cc80245 100644 --- a/src/core/features/login/lang.json +++ b/src/core/features/login/lang.json @@ -27,6 +27,8 @@ "errorexampleurl": "The URL https://campus.example.edu is only an example URL, it's not a real site. Please use the URL of your school or organization's site.", "errorqrnoscheme": "This URL isn't a valid login URL.", "errorupdatesite": "An error occurred while updating the site's token.", + "exceededloginattempts": "Need help logging in? Try recovering your password or contact your site support", + "exceededloginattemptssupportsubject": "I can't log in", "faqcannotconnectanswer": "Please, contact your site administrator.", "faqcannotconnectquestion": "I typed my site address correctly but I still can't connect.", "faqcannotfindmysiteanswer": "Have you typed the name correctly? It's also possible that your site is not included in our public sites directory. If you still can't find it, please enter your site address instead.", diff --git a/src/core/features/login/pages/credentials/credentials.html b/src/core/features/login/pages/credentials/credentials.html index 84f969322..a9f727baf 100644 --- a/src/core/features/login/pages/credentials/credentials.html +++ b/src/core/features/login/pages/credentials/credentials.html @@ -30,6 +30,11 @@

{{siteUrl}}

+ + {{ 'core.login.exceededloginattempts' | translate }} + +