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; +}