From 232669855a668997745c421891a8d4d3227b0868 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 29 Oct 2020 13:44:26 +0100 Subject: [PATCH] MOBILE-3565 components: Create input-errors, mark-required and recaptcha --- src/app/components/components.module.ts | 12 ++ .../input-errors/core-input-errors.html | 16 +++ .../components/input-errors/input-errors.scss | 16 +++ .../components/input-errors/input-errors.ts | 86 +++++++++++++ .../mark-required/core-mark-required.html | 3 + .../mark-required/mark-required.scss | 8 ++ .../components/mark-required/mark-required.ts | 78 +++++++++++ .../components/recaptcha/core-recaptcha.html | 13 ++ .../recaptcha/core-recaptchamodal.html | 14 ++ src/app/components/recaptcha/recaptcha.ts | 85 ++++++++++++ .../components/recaptcha/recaptchamodal.ts | 121 ++++++++++++++++++ 11 files changed, 452 insertions(+) create mode 100644 src/app/components/input-errors/core-input-errors.html create mode 100644 src/app/components/input-errors/input-errors.scss create mode 100644 src/app/components/input-errors/input-errors.ts create mode 100644 src/app/components/mark-required/core-mark-required.html create mode 100644 src/app/components/mark-required/mark-required.scss create mode 100644 src/app/components/mark-required/mark-required.ts create mode 100644 src/app/components/recaptcha/core-recaptcha.html create mode 100644 src/app/components/recaptcha/core-recaptchamodal.html create mode 100644 src/app/components/recaptcha/recaptcha.ts create mode 100644 src/app/components/recaptcha/recaptchamodal.ts diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index a6bf120a8..53d44a614 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -19,7 +19,11 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreIconComponent } from './icon/icon'; import { CoreIframeComponent } from './iframe/iframe'; +import { CoreInputErrorsComponent } from './input-errors/input-errors'; import { CoreLoadingComponent } from './loading/loading'; +import { CoreMarkRequiredComponent } from './mark-required/mark-required'; +import { CoreRecaptchaComponent } from './recaptcha/recaptcha'; +import { CoreRecaptchaModalComponent } from './recaptcha/recaptchamodal'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreDirectivesModule } from '@app/directives/directives.module'; @@ -29,7 +33,11 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; declarations: [ CoreIconComponent, CoreIframeComponent, + CoreInputErrorsComponent, CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, ], @@ -43,7 +51,11 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; exports: [ CoreIconComponent, CoreIframeComponent, + CoreInputErrorsComponent, CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, ], diff --git a/src/app/components/input-errors/core-input-errors.html b/src/app/components/input-errors/core-input-errors.html new file mode 100644 index 000000000..9f216a964 --- /dev/null +++ b/src/app/components/input-errors/core-input-errors.html @@ -0,0 +1,16 @@ + diff --git a/src/app/components/input-errors/input-errors.scss b/src/app/components/input-errors/input-errors.scss new file mode 100644 index 000000000..de3f96567 --- /dev/null +++ b/src/app/components/input-errors/input-errors.scss @@ -0,0 +1,16 @@ +:host { + width: 100%; + + .core-input-error-container { + .core-input-error { + padding: 4px; + color: var(--ion-color-danger); + font-size: 12px; + display: none; + + &:first-child { + display: block; + } + } + } +} diff --git a/src/app/components/input-errors/input-errors.ts b/src/app/components/input-errors/input-errors.ts new file mode 100644 index 000000000..1bad391ab --- /dev/null +++ b/src/app/components/input-errors/input-errors.ts @@ -0,0 +1,86 @@ +// (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, OnChanges, SimpleChange } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Component to show errors if an input isn't valid. + * + * @description + * The purpose of this component is to make easier and consistent the validation of forms. + * + * It should be applied next to the input element (ion-input, ion-select, ...). In case of ion-checkbox, it should be in another + * item, placing it in the same item as the checkbox will cause problems. + * + * Please notice that the inputs need to have a FormControl to make it work. That FormControl needs to be passed to this component. + * + * If this component is placed in the same ion-item as a ion-label or ion-input, then it should have the attribute "item-content", + * otherwise Ionic will remove it. + * + * Example usage: + * + * + * {{ 'core.login.username' | translate }} + * + * + * + */ +@Component({ + selector: 'core-input-errors', + templateUrl: 'core-input-errors.html', + styleUrls: ['input-errors.scss'], +}) +export class CoreInputErrorsComponent implements OnChanges { + + @Input() control?: FormControl; + @Input() errorMessages?: Record; + @Input() errorText?: string; // Set other non automatic errors. + errorKeys: string[] = []; + + /** + * Initialize some common errors if they aren't set. + */ + protected initErrorMessages(): void { + this.errorMessages = this.errorMessages || {}; + + this.errorMessages.required = this.errorMessages.required || Translate.instance.instant('core.required'); + this.errorMessages.email = this.errorMessages.email || Translate.instance.instant('core.login.invalidemail'); + this.errorMessages.date = this.errorMessages.date || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.datetime = this.errorMessages.datetime || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.datetimelocal = this.errorMessages.datetimelocal || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.time = this.errorMessages.time || Translate.instance.instant('core.login.invalidtime'); + this.errorMessages.url = this.errorMessages.url || Translate.instance.instant('core.login.invalidurl'); + + // Set empty values by default, the default error messages will be built in the template when needed. + this.errorMessages.max = this.errorMessages.max || ''; + this.errorMessages.min = this.errorMessages.min || ''; + } + + /** + * Component being changed. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if ((changes.control || changes.errorMessages) && this.control) { + this.initErrorMessages(); + + this.errorKeys = this.errorMessages ? Object.keys(this.errorMessages) : []; + } + if (changes.errorText) { + this.errorText = changes.errorText.currentValue; + } + } + +} diff --git a/src/app/components/mark-required/core-mark-required.html b/src/app/components/mark-required/core-mark-required.html new file mode 100644 index 000000000..bf5cd4897 --- /dev/null +++ b/src/app/components/mark-required/core-mark-required.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/components/mark-required/mark-required.scss b/src/app/components/mark-required/mark-required.scss new file mode 100644 index 000000000..def9d3ecb --- /dev/null +++ b/src/app/components/mark-required/mark-required.scss @@ -0,0 +1,8 @@ +:host { + .core-input-required-asterisk { + font-size: 8px; + --padding-start: 4px; + line-height: 100%; + vertical-align: top; + } +} diff --git a/src/app/components/mark-required/mark-required.ts b/src/app/components/mark-required/mark-required.ts new file mode 100644 index 000000000..c62e30bd6 --- /dev/null +++ b/src/app/components/mark-required/mark-required.ts @@ -0,0 +1,78 @@ +// (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, AfterViewInit, ElementRef } from '@angular/core'; + +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Directive to add a red asterisk for required input fields. + * + * @description + * For forms with required and not required fields, it is recommended to use this directive to mark the required ones. + * + * This directive should be applied in the label. Example: + * + * {{ 'core.login.username' | translate }} + */ +@Component({ + selector: '[core-mark-required]', + templateUrl: 'core-mark-required.html', + styleUrls: ['mark-required.scss'], +}) +export class CoreMarkRequiredComponent implements OnInit, AfterViewInit { + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('core-mark-required') coreMarkRequired: boolean | string = true; + + protected element: HTMLElement; + requiredLabel?: string; + + constructor( + element: ElementRef, + ) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.requiredLabel = Translate.instance.instant('core.required'); + this.coreMarkRequired = CoreUtils.instance.isTrueOrOne(this.coreMarkRequired); + } + + /** + * Called after the view is initialized. + */ + ngAfterViewInit(): void { + if (this.coreMarkRequired) { + // Add the "required" to the aria-label. + const ariaLabel = this.element.getAttribute('aria-label') || + CoreTextUtils.instance.cleanTags(this.element.innerHTML, true); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel + ' ' + this.requiredLabel); + } + } else { + // Remove the "required" from the aria-label. + const ariaLabel = this.element.getAttribute('aria-label'); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel.replace(' ' + this.requiredLabel, '')); + } + } + } + +} diff --git a/src/app/components/recaptcha/core-recaptcha.html b/src/app/components/recaptcha/core-recaptcha.html new file mode 100644 index 000000000..d094e17dc --- /dev/null +++ b/src/app/components/recaptcha/core-recaptcha.html @@ -0,0 +1,13 @@ + +
+ + + {{ 'core.resourcedisplayopen' | translate }} + + + {{ 'core.answered' | translate }} + + + {{ 'core.login.recaptchaexpired' | translate }} + +
diff --git a/src/app/components/recaptcha/core-recaptchamodal.html b/src/app/components/recaptcha/core-recaptchamodal.html new file mode 100644 index 000000000..e49d8989b --- /dev/null +++ b/src/app/components/recaptcha/core-recaptchamodal.html @@ -0,0 +1,14 @@ + + + {{ 'core.login.security_question' | translate }} + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/components/recaptcha/recaptcha.ts b/src/app/components/recaptcha/recaptcha.ts new file mode 100644 index 000000000..8edc8b2b0 --- /dev/null +++ b/src/app/components/recaptcha/recaptcha.ts @@ -0,0 +1,85 @@ +// (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 { CoreLang } from '@services/lang'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { ModalController } from '@singletons/core.singletons'; +import { CoreRecaptchaModalComponent } from './recaptchamodal'; + +/** + * Component that allows answering a recaptcha. + */ +@Component({ + selector: 'core-recaptcha', + templateUrl: 'core-recaptcha.html', +}) +export class CoreRecaptchaComponent implements OnInit { + + @Input() model?: Record; // The model where to store the recaptcha response. + @Input() publicKey?: string; // The site public key. + @Input() modelValueName = 'recaptcharesponse'; // Name of the model property where to store the response. + @Input() siteUrl?: string; // The site URL. If not defined, current site. + + expired = false; + + protected lang?: string; + + constructor() { + this.initLang(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.siteUrl = this.siteUrl || CoreSites.instance.getCurrentSite()?.getURL(); + } + + /** + * Initialize the lang property. + */ + protected async initLang(): Promise { + this.lang = await CoreLang.instance.getCurrentLanguage(); + } + + /** + * Open the recaptcha modal. + */ + async answerRecaptcha(): Promise { + // Set the iframe src. We use an iframe because reCaptcha V2 doesn't work with file:// protocol. + const src = CoreTextUtils.instance.concatenatePaths(this.siteUrl!, 'webservice/recaptcha.php?lang=' + this.lang); + + // Modal to answer the recaptcha. + // This is because the size of the recaptcha is dynamic, so it could cause problems if it was displayed inline. + + const modal = await ModalController.instance.create({ + component: CoreRecaptchaModalComponent, + cssClass: 'core-modal-fullscreen', + componentProps: { + recaptchaUrl: src, + }, + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + this.expired = result.data.expired; + this.model![this.modelValueName] = result.data.value; + } + +} diff --git a/src/app/components/recaptcha/recaptchamodal.ts b/src/app/components/recaptcha/recaptchamodal.ts new file mode 100644 index 000000000..5a56bc2a0 --- /dev/null +++ b/src/app/components/recaptcha/recaptchamodal.ts @@ -0,0 +1,121 @@ +// (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, OnDestroy } from '@angular/core'; + +import { ModalController } from '@singletons/core.singletons'; + +/** + * Component to display a the recaptcha in a modal. + */ +@Component({ + selector: 'core-recaptcha-modal', + templateUrl: 'core-recaptchamodal.html', +}) +export class CoreRecaptchaModalComponent implements OnDestroy { + + @Input() recaptchaUrl?: string; + + expired = false; + value = ''; + + protected messageListenerFunction: (event: MessageEvent) => Promise; + + constructor() { + // Listen for messages from the iframe. + this.messageListenerFunction = this.onIframeMessage.bind(this); + window.addEventListener('message', this.messageListenerFunction); + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.instance.dismiss({ + expired: this.expired, + value: this.value, + }); + } + + /** + * The iframe with the recaptcha was loaded. + * + * @param iframe Iframe element. + */ + loaded(iframe: HTMLIFrameElement): void { + // Search the iframe content. + const contentWindow = iframe?.contentWindow; + + if (contentWindow) { + try { + // Set the callbacks we're interested in. + contentWindow['recaptchacallback'] = this.onRecaptchaCallback.bind(this); + contentWindow['recaptchaexpiredcallback'] = this.onRecaptchaExpiredCallback.bind(this); + } catch (error) { + // Cannot access the window. + } + } + } + + /** + * Treat an iframe message event. + * + * @param event Event. + * @return Promise resolved when done. + */ + protected async onIframeMessage(event: MessageEvent): Promise { + if (!event.data || event.data.environment != 'moodleapp' || event.data.context != 'recaptcha') { + return; + } + + switch (event.data.action) { + case 'callback': + this.onRecaptchaCallback(event.data.value); + break; + case 'expired': + this.onRecaptchaExpiredCallback(); + break; + + default: + break; + } + } + + /** + * Recapcha callback called. + * + * @param value Value received. + */ + protected onRecaptchaCallback(value: string): void { + this.expired = false; + this.value = value; + this.closeModal(); + } + + /** + * Recapcha expired callback called. + */ + protected onRecaptchaExpiredCallback(): void { + this.expired = true; + this.value = ''; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + window.removeEventListener('message', this.messageListenerFunction); + } + +}