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 7cd0b2a57..a022f6a59 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1471,8 +1471,6 @@ "core.calculating": "local_moodlemobileapp", "core.cancel": "moodle", "core.cannotconnect": "local_moodlemobileapp", - "core.cannotconnecttrouble": "local_moodlemobileapp", - "core.cannotconnectverify": "local_moodlemobileapp", "core.cannotdownloadfiles": "local_moodlemobileapp", "core.cannotinstallapk": "local_moodlemobileapp", "core.cannotlogoutpageblocks": "local_moodlemobileapp", @@ -1519,7 +1517,9 @@ "core.confirmleaveunknownchanges": "local_moodlemobileapp", "core.confirmloss": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp", + "core.connectionlost": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", + "core.contactsupport": "local_moodlemobileapp", "core.content": "moodle", "core.contenteditingsynced": "local_moodlemobileapp", "core.contentlinks.chooseaccount": "local_moodlemobileapp", @@ -1696,7 +1696,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", @@ -1908,16 +1911,17 @@ "core.login.changepasswordbutton": "local_moodlemobileapp", "core.login.changepasswordhelp": "local_moodlemobileapp", "core.login.changepasswordreconnectinstructions": "local_moodlemobileapp", + "core.login.changepasswordsupportsubject": "local_moodlemobileapp", "core.login.confirmdeletesite": "local_moodlemobileapp", "core.login.connect": "local_moodlemobileapp", "core.login.connecttomoodle": "local_moodlemobileapp", "core.login.connecttomoodleapp": "local_moodlemobileapp", "core.login.connecttoworkplaceapp": "local_moodlemobileapp", - "core.login.contactyouradministrator": "local_moodlemobileapp", - "core.login.contactyouradministratorissue": "local_moodlemobileapp", "core.login.createaccount": "moodle", "core.login.createuserandpass": "moodle", "core.login.credentialsdescription": "local_moodlemobileapp", + "core.login.credentialshelp": "local_moodlemobileapp", + "core.login.credentialssupportsubject": "local_moodlemobileapp", "core.login.emailconfirmsent": "moodle", "core.login.emailconfirmsentnoemail": "local_moodlemobileapp", "core.login.emailconfirmsentsuccess": "moodle", @@ -1927,8 +1931,11 @@ "core.login.errorexampleurl": "local_moodlemobileapp", "core.login.errorqrnoscheme": "local_moodlemobileapp", "core.login.errorupdatesite": "local_moodlemobileapp", - "core.login.faqcannotconnectanswer": "local_moodlemobileapp", - "core.login.faqcannotconnectquestion": "local_moodlemobileapp", + "core.login.exceededloginattempts": "local_moodlemobileapp", + "core.login.exceededloginattemptsfallback": "local_moodlemobileapp", + "core.login.exceededloginattemptssupportsubject": "local_moodlemobileapp", + "core.login.exceededpasswordresetattempts": "local_moodlemobileapp", + "core.login.exceededpasswordresetattemptssupportsubject": "local_moodlemobileapp", "core.login.faqcannotfindmysiteanswer": "local_moodlemobileapp", "core.login.faqcannotfindmysitequestion": "local_moodlemobileapp", "core.login.faqsetupsiteanswer": "local_moodlemobileapp", @@ -1945,7 +1952,6 @@ "core.login.forcepasswordchangenotice": "moodle", "core.login.forgotten": "moodle", "core.login.help": "moodle", - "core.login.helpmelogin": "local_moodlemobileapp", "core.login.instructions": "auth", "core.login.invalidaccount": "local_moodlemobileapp", "core.login.invaliddate": "calendar/errorinvaliddate", @@ -1994,7 +2000,9 @@ "core.login.recaptchaexpired": "local_moodlemobileapp", "core.login.recaptchaincorrect": "local_moodlemobileapp", "core.login.reconnect": "local_moodlemobileapp", + "core.login.reconnecthelp": "local_moodlemobileapp", "core.login.reconnectssodescription": "local_moodlemobileapp", + "core.login.reconnectsupportsubject": "local_moodlemobileapp", "core.login.reconnecttosite": "local_moodlemobileapp", "core.login.removeaccount": "local_moodlemobileapp", "core.login.resendemail": "moodle", @@ -2266,6 +2274,9 @@ "core.sitehome.sitehome": "moodle", "core.sitehome.sitenews": "moodle", "core.sitemaintenance": "admin", + "core.sitenotfound": "local_moodlemobileapp", + "core.sitenotfoundhelp": "local_moodlemobileapp", + "core.siteunavailablehelp": "local_moodlemobileapp", "core.size": "moodle", "core.sizeb": "moodle", "core.sizegb": "moodle", @@ -2338,8 +2349,10 @@ "core.user.address": "moodle", "core.user.city": "moodle", "core.user.completeprofile": "local_moodlemobileapp", + "core.user.completeprofilehelp": "local_moodlemobileapp", "core.user.completeprofilenotice": "local_moodlemobileapp", "core.user.completeprofilereconnectinstructions": "local_moodlemobileapp", + "core.user.completeprofilesupportsubject": "local_moodlemobileapp", "core.user.completeyourprofile": "local_moodlemobileapp", "core.user.contact": "local_moodlemobileapp", "core.user.country": "moodle", @@ -2363,6 +2376,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", @@ -2385,7 +2399,6 @@ "core.weeks": "moodle", "core.whatisyourage": "moodle", "core.wheredoyoulive": "moodle", - "core.whoissiteadmin": "local_moodlemobileapp", "core.whyisthishappening": "local_moodlemobileapp", "core.whyisthisrequired": "moodle", "core.wsfunctionnotavailable": "local_moodlemobileapp", 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/addons/mod/quiz/services/quiz-sync.ts b/src/addons/mod/quiz/services/quiz-sync.ts index 62904829f..1dcacd5c1 100644 --- a/src/addons/mod/quiz/services/quiz-sync.ts +++ b/src/addons/mod/quiz/services/quiz-sync.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreError } from '@classes/errors/error'; +import { CoreSite } from '@classes/site'; import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; import { CoreCourse, CoreCourseModuleBasicInfo } from '@features/course/services/course'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; @@ -313,7 +314,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider if (!CoreNetwork.isOnline()) { // Cannot sync in offline. - throw new CoreError(Translate.instant('core.cannotconnect')); + throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); } const offlineAttempt = offlineAttempts.pop()!; diff --git a/src/addons/mod/scorm/lang.json b/src/addons/mod/scorm/lang.json index 3e234713d..477497e32 100644 --- a/src/addons/mod/scorm/lang.json +++ b/src/addons/mod/scorm/lang.json @@ -15,7 +15,7 @@ "errordownloadscorm": "Error downloading SCORM: \"{{name}}\".", "errorgetscorm": "Error getting SCORM data.", "errorinvalidversion": "Sorry, the application only supports SCORM 1.2.", - "errornotdownloadable": "The download of SCORM packages is disabled. Please contact your site administrator.", + "errornotdownloadable": "Your school or learning provider has disabled the download of SCORM packages.", "errornovalidsco": "This SCORM package doesn't have a visible SCO to load.", "errorpackagefile": "Sorry, the application only supports ZIP packages.", "errorsyncscorm": "An error occurred while synchronising. Please try again.", @@ -49,4 +49,4 @@ "toc": "TOC", "warningofflinedatadeleted": "Some offline data from attempt {{number}} has been discarded because it couldn't be counted as a new attempt.", "warningsynconlineincomplete": "Some attempts couldn't be synchronised with the site because the last online attempt is not yet finished. Please finish the online attempt first." -} \ No newline at end of file +} 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/classes/errors/ajaxerror.ts b/src/core/classes/errors/ajaxerror.ts index 70f5eeee1..9761403c9 100644 --- a/src/core/classes/errors/ajaxerror.ts +++ b/src/core/classes/errors/ajaxerror.ts @@ -12,18 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreError } from '@classes/errors/error'; +import { CoreSiteError, CoreSiteErrorOptions } from '@classes/errors/siteerror'; /** * Generic error returned by an Ajax call. */ -export class CoreAjaxError extends CoreError { +export class CoreAjaxError extends CoreSiteError { available = 1; // @deprecated since app 4.0. AJAX endpoint should always be available in supported Moodle versions. status?: number; - constructor(message: string, available?: number, status?: number) { - super(message); + constructor(messageOrOptions: string | CoreSiteErrorOptions, available?: number, status?: number) { + super(typeof messageOrOptions === 'string' ? { message: messageOrOptions } : messageOrOptions); this.status = status; } diff --git a/src/core/classes/errors/ajaxwserror.ts b/src/core/classes/errors/ajaxwserror.ts index 5a114f033..73dcfa37d 100644 --- a/src/core/classes/errors/ajaxwserror.ts +++ b/src/core/classes/errors/ajaxwserror.ts @@ -12,15 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreError } from '@classes/errors/error'; +import { CoreSiteError } from '@classes/errors/siteerror'; /** * Error returned by WS. */ -export class CoreAjaxWSError extends CoreError { +export class CoreAjaxWSError extends CoreSiteError { exception?: string; // Name of the Moodle exception. - errorcode?: string; warningcode?: string; link?: string; // Link to the site. moreinfourl?: string; // Link to a page with more info. @@ -30,10 +29,12 @@ export class CoreAjaxWSError extends CoreError { // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor(error: any, available?: number) { - super(error.message || error.error); + super({ + message: error.message || error.error, + errorcode: error.errorcode, + }); this.exception = error.exception; - this.errorcode = error.errorcode; this.warningcode = error.warningcode; this.link = error.link; this.moreinfourl = error.moreinfourl; diff --git a/src/core/classes/errors/errors.ts b/src/core/classes/errors/errors.ts index 9a4b21a23..3c4f6877a 100644 --- a/src/core/classes/errors/errors.ts +++ b/src/core/classes/errors/errors.ts @@ -23,6 +23,7 @@ import { CoreAjaxWSError } from './ajaxwserror'; import { CoreCaptureError } from './captureerror'; import { CoreNetworkError } from './network-error'; import { CoreSiteError } from './siteerror'; +import { CoreLoginError } from './loginerror'; import { CoreErrorWithOptions } from './errorwithtitle'; import { CoreHttpError } from './httperror'; @@ -35,6 +36,7 @@ export const CORE_ERRORS_CLASSES: Type[] = [ CoreNetworkError, CoreSilentError, CoreSiteError, + CoreLoginError, CoreWSError, CoreErrorWithOptions, CoreHttpError, diff --git a/src/core/classes/errors/loginerror.ts b/src/core/classes/errors/loginerror.ts new file mode 100644 index 000000000..f77688b88 --- /dev/null +++ b/src/core/classes/errors/loginerror.ts @@ -0,0 +1,40 @@ +// (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 { CoreSiteError, CoreSiteErrorOptions } from '@classes/errors/siteerror'; + +/** + * Error returned when performing operations during login. + */ +export class CoreLoginError extends CoreSiteError { + + title?: string; + critical?: boolean; + loggedOut?: boolean; + + constructor(options: CoreLoginErrorOptions) { + super(options); + + this.title = options.title; + this.critical = options.critical; + this.loggedOut = options.loggedOut; + } + +} + +export type CoreLoginErrorOptions = CoreSiteErrorOptions & { + title?: string; // Error title. + critical?: boolean; // Whether the error is important enough to abort the operation. + loggedOut?: boolean; // Whether site has been marked as logged out. +}; diff --git a/src/core/classes/errors/siteerror.ts b/src/core/classes/errors/siteerror.ts index a459a0257..bb31c41b6 100644 --- a/src/core/classes/errors/siteerror.ts +++ b/src/core/classes/errors/siteerror.ts @@ -13,29 +13,33 @@ // limitations under the License. import { CoreError } from '@classes/errors/error'; +import { CoreUserSupportConfig } from '@features/user/classes/support/support-config'; /** - * Error returned when performing operations regarding a site (check if it exists, authenticate user, etc.). + * Error returned when performing operations regarding a site. */ export class CoreSiteError extends CoreError { errorcode?: string; - critical?: boolean; - loggedOut?: boolean; + errorDetails?: string; + supportConfig?: CoreUserSupportConfig; - 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.errorDetails = options.errorDetails; + this.supportConfig = options.supportConfig; } } -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. + errorcode?: string; // Technical error code useful for technical assistance. + errorDetails?: string; // Technical error details useful for technical assistance. + + // Configuration to use to contact site support. If this attribute is present, it means + // that the error warrants contacting support. + supportConfig?: CoreUserSupportConfig; }; diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 0af78f156..bc20704b5 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -60,6 +60,8 @@ import { import { Observable, ObservableInput, ObservedValueOf, OperatorFunction, Subject } from 'rxjs'; import { finalize, map, mergeMap } from 'rxjs/operators'; import { firstValueFrom } from '../utils/rxjs'; +import { CoreSiteError } from '@classes/errors/siteerror'; +import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config'; /** * QR Code type enumeration. @@ -808,9 +810,7 @@ export class CoreSite { ): Promise { if (preSets.forceOffline) { // Don't call the WS, just fail. - throw new CoreError( - Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), - ); + throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); } try { @@ -1130,7 +1130,12 @@ export class CoreSite { ); if (!data || !data.responses) { - throw new CoreError(Translate.instant('core.errorinvalidresponse')); + throw new CoreSiteError({ + supportConfig: new CoreUserAuthenticatedSupportConfig(this), + message: Translate.instant('core.siteunavailablehelp', { site: this.siteUrl }), + errorcode: 'invalidresponse', + errorDetails: Translate.instant('core.errorinvalidresponse', { method: 'tool_mobile_call_external_functions' }), + }); } requests.forEach((request, i) => { @@ -1670,6 +1675,10 @@ export class CoreSite { */ async getPublicConfig(options: { readingStrategy?: CoreSitesReadingStrategy } = {}): Promise { if (!this.db) { + if (options.readingStrategy === CoreSitesReadingStrategy.ONLY_CACHE) { + throw new CoreError('Cache not available to read public config'); + } + return this.requestPublicConfig(); } @@ -1714,9 +1723,7 @@ export class CoreSite { .catch(async () => { if (cachePreSets.forceOffline) { // Don't call the WS, just fail. - throw new CoreError( - Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), - ); + throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); } // Call the WS. @@ -2742,10 +2749,21 @@ export type CoreSiteConfigResponse = { warnings?: CoreWSExternalWarning[]; }; +/** + * Possible values for 'supportavailability' config. + */ +export const enum CoreSiteConfigSupportAvailability { + Disabled = 0, + Authenticated = 1, + Anyone = 2, +} + /** * Site config indexed by name. */ -export type CoreSiteConfig = {[name: string]: string}; +export type CoreSiteConfig = Record & { + supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability. +}; /** * Result of WS tool_mobile_get_public_config. @@ -2777,6 +2795,8 @@ 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). + supportavailability?: CoreSiteConfigSupportAvailability; + 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/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/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/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/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/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; diff --git a/src/core/directives/tests/format-text.test.ts b/src/core/directives/tests/format-text.test.ts index 0face19cd..843e76742 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: [] }) }); @@ -59,12 +67,12 @@ 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 () => { // Arrange - mockSingleton(CoreFilter, { formatText: () => 'Formatted text' }); + mockSingleton(CoreFilter, { formatText: () => Promise.resolve('Formatted text') }); // Act const { nativeElement } = await renderTemplate( @@ -75,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( @@ -107,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( @@ -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 @@ -145,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); @@ -163,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/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 + + + +

+ +

+ + {{ '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..867dc4e45 --- /dev/null +++ b/src/core/features/login/components/exceeded-attempts/exceeded-attempts.ts @@ -0,0 +1,48 @@ +// (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 { CoreUserSupportConfig } from '@features/user/classes/support/support-config'; +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() supportConfig!: CoreUserSupportConfig; + @Input() supportSubject?: string; + + canContactSupport = false; + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.canContactSupport = this.supportConfig.canContactSupport(); + } + + /** + * Contact site support. + */ + async contactSupport(): Promise { + await CoreUserSupport.contact({ + supportConfig: this.supportConfig, + subject: this.supportSubject, + }); + } + +} diff --git a/src/core/features/login/components/site-help/site-help.html b/src/core/features/login/components/site-help/site-help.html index 502818a75..25304a89a 100644 --- a/src/core/features/login/components/site-help/site-help.html +++ b/src/core/features/login/components/site-help/site-help.html @@ -12,34 +12,24 @@ - - -

{{ 'core.login.faqcannotfindmysitequestion' | translate }}

-
-
- - -

{{ 'core.login.faqcannotfindmysiteanswer' | translate }}

-
-

{{ 'core.login.faqwhatisurlquestion' | translate }}

-