MOBILE-3565 components: Create input-errors, mark-required and recaptcha

main
Dani Palou 2020-10-29 13:44:26 +01:00
parent 51e220f497
commit 232669855a
11 changed files with 452 additions and 0 deletions

View File

@ -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,
],

View File

@ -0,0 +1,16 @@
<div class="core-input-error-container" role="alert" *ngIf="(control && control.dirty && !control.valid) || errorText">
<ng-container *ngIf="control && control.dirty && !control.valid">
<ng-container *ngFor="let error of errorKeys">
<div *ngIf="control.hasError(error)" class="core-input-error">
<span *ngIf="errorMessages[error]">{{errorMessages[error]}}</span>
<span *ngIf="!errorMessages[error] && error == 'max' && control.errors.max">
{{ 'core.login.invalidvaluemax' | translate:{$a: control.errors.max.max} }}
</span>
<span *ngIf="!errorMessages[error] && error == 'min' && control.errors.min">
{{ 'core.login.invalidvaluemin' | translate:{$a: control.errors.min.min} }}
</span>
</div>
</ng-container>
</ng-container>
<div *ngIf="errorText" class="core-input-error" aria-live="assertive">{{ errorText }}</div>
</div>

View File

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

View File

@ -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:
*
* <ion-item text-wrap>
* <ion-label stacked core-mark-required="true">{{ 'core.login.username' | translate }}</ion-label>
* <ion-input type="text" name="username" formControlName="username"></ion-input>
* <core-input-errors item-content [control]="myForm.controls.username" [errorMessages]="usernameErrors"></core-input-errors>
* </ion-item>
*/
@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<string, string>;
@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;
}
}
}

View File

@ -0,0 +1,3 @@
<ng-content></ng-content>
<ion-icon *ngIf="coreMarkRequired" class="core-input-required-asterisk" name="fas-asterisk" color="danger" [title]="requiredLabel">
</ion-icon>

View File

@ -0,0 +1,8 @@
:host {
.core-input-required-asterisk {
font-size: 8px;
--padding-start: 4px;
line-height: 100%;
vertical-align: top;
}
}

View File

@ -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:
*
* <ion-label core-mark-required="{{field.required}}">{{ 'core.login.username' | translate }}</ion-label>
*/
@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, ''));
}
}
}
}

View File

@ -0,0 +1,13 @@
<!-- ReCAPTCHA V2 -->
<div *ngIf="publicKey && siteUrl && model">
<!-- A button to open the recaptcha modal. -->
<ion-button expand="block" color="light" class="ion-margin" *ngIf="!model[modelValueName]" (click)="answerRecaptcha()">
{{ 'core.resourcedisplayopen' | translate }}
</ion-button>
<ion-item *ngIf="model[modelValueName]">
<ion-label color="success">{{ 'core.answered' | translate }}</ion-label>
</ion-item>
<ion-item *ngIf="expired">
<ion-label color="danger">{{ 'core.login.recaptchaexpired' | translate }}</ion-label>
</ion-item>
</div>

View File

@ -0,0 +1,14 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ 'core.login.security_question' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fa-times"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<core-iframe [src]="recaptchaUrl" (loaded)="loaded($event)"></core-iframe>
</ion-content>

View File

@ -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<string, string>; // 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<void> {
this.lang = await CoreLang.instance.getCurrentLanguage();
}
/**
* Open the recaptcha modal.
*/
async answerRecaptcha(): Promise<void> {
// 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;
}
}

View File

@ -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<void>;
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<void> {
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);
}
}