Adds fake sign-in and sign-out components

This commit is contained in:
Aaron Louie 2020-02-24 14:19:20 -05:00
parent 4cf28994b6
commit f9a47e36f7
15 changed files with 603 additions and 15 deletions

View File

@ -0,0 +1,4 @@
// tslint:disable-next-line:max-line-length
export const EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i;
export default EMAIL_REGEX;

View File

@ -0,0 +1,36 @@
import {FormControl} from '@angular/forms';
import {ValidateEmail} from './email.validator';
describe('ValidateEmail', () => {
const control = new FormControl();
it('should return an error for an invalid email address', () => {
const emailsToTest = [
'124535',
'not an email address',
'@gmail.com',
'incomplete@domain',
'tooshort@tld.c',
];
for (const email of emailsToTest) {
control.setValue(email);
expect(ValidateEmail(control)).toEqual({email: true});
}
});
it('should not return an error for a valid email address', () => {
const emailsToTest = [
'short@tld.co',
'simple@email.edu',
'more+complicated@www.email-server.mail',
'this.is.a.valid.email+address@some-random.email-server.com',
];
for (const email of emailsToTest) {
control.setValue(email);
expect(ValidateEmail(control)).toBeUndefined();
}
});
});

View File

@ -0,0 +1,9 @@
import { AbstractControl, ValidationErrors } from '@angular/forms';
import EMAIL_REGEX from './email.regex';
export function ValidateEmail(control: AbstractControl): ValidationErrors {
if (!EMAIL_REGEX.test(control.value) && control.value && control.value !== '') {
const error: ValidationErrors = { email: true };
return error;
}
}

View File

@ -0,0 +1,142 @@
import {FormControl} from '@angular/forms';
import {FormlyFieldConfig} from '@ngx-formly/core';
import {FieldType} from '@ngx-formly/material';
import * as Validators from './formly.validator';
describe('Formly Validators', () => {
let control: FormControl;
let err: Error;
let field: FormlyFieldConfig;
beforeEach(() => {
control = new FormControl();
err = new Error('some error');
field = {
type: 'email',
templateOptions: {},
formControl: control,
};
});
it('should validate emails', () => {
control.setValue('');
expect(Validators.EmailValidator(control)).toBeNull();
control.setValue('a@b');
expect(Validators.EmailValidator(control)).toEqual({email: true});
control.setValue('a@b.c');
expect(Validators.EmailValidator(control)).toEqual({email: true});
control.setValue('a@b.com');
expect(Validators.EmailValidator(control)).toBeNull();
});
it('should email validator message', () => {
control.setValue('bad_email');
expect(Validators.EmailValidatorMessage(err, field)).toContain('bad_email');
});
it('should validate URL strings', () => {
const badUrls = [
'bad_url',
'http://',
'http://bad',
'bad.com',
];
badUrls.forEach(url => {
control.setValue(url);
expect(Validators.UrlValidator(control)).toEqual({url: true});
});
const goodUrls = [
'http://good.url.com',
'ftp://who-uses.ftp-these-days.com',
'https://this.is.actually:5432/a/valid/url?doncha=know#omg',
];
goodUrls.forEach(url => {
control.setValue(url);
expect(Validators.UrlValidator(control)).toBeNull();
});
});
it('should return a url validator message', () => {
control.setValue('bad_url');
expect(Validators.UrlValidatorMessage(err, field)).toContain('bad_url');
});
it('should validate phone numbers', () => {
const badPhones = [
'not a phone number',
'54321',
'3.1415926535897932384...',
'(800) CALL-BRAD',
'(540) 155-5555',
'(999) 654-3210',
'1234567890',
];
badPhones.forEach(phone => {
control.setValue(phone);
expect(Validators.PhoneValidator(control)).toEqual({phone: true});
});
const goodPhones = [
'12345678909',
'1-800-555-5555',
'(987) 654-3210',
'(540) 555-1212 x321',
'(540) 555-1212 ext 321',
'(540) 555-1212 Ext. 321',
'540.555.5555',
'540.555.5555extension321',
];
goodPhones.forEach(phone => {
control.setValue(phone);
expect(Validators.PhoneValidator(control)).toBeNull();
});
});
it('should return phone validator message', () => {
control.setValue('bad_number');
expect(Validators.PhoneValidatorMessage(err, field)).toContain('bad_number');
});
it('should return error if at least one checkbox in multicheckbox is not checked', () => {
control.setValue({});
expect(Validators.MulticheckboxValidator(control)).toEqual({required: true});
control.setValue({
checkbox_a: true,
checkbox_b: false,
checkbox_c: false,
});
expect(Validators.MulticheckboxValidator(control)).toBeNull();
});
it('should return multicheckbox validator message', () => {
control.setValue({});
expect(Validators.MulticheckboxValidatorMessage(err, field))
.toEqual('At least one of these checkboxes must be selected.');
});
it('should min validation message', () => {
field.templateOptions.min = 42;
expect(Validators.MinValidationMessage(err, field)).toContain('42');
});
it('should max validation message', () => {
field.templateOptions.max = 42;
expect(Validators.MaxValidationMessage(err, field)).toContain('42');
});
it('should show error', () => {
expect(Validators.ShowError(field as FieldType)).toBeFalsy();
control.setErrors({url: true});
control.markAsDirty();
expect(Validators.ShowError(field as FieldType)).toBeTruthy();
field.formControl = undefined;
expect(Validators.ShowError(field as FieldType)).toBeFalsy();
});
});

View File

@ -0,0 +1,62 @@
import {FormControl, ValidationErrors} from '@angular/forms';
import {FieldType, FormlyFieldConfig} from '@ngx-formly/core';
import EMAIL_REGEX from './email.regex';
import PHONE_REGEX from './phone.regex';
import URL_REGEX from './url.regex';
export function EmailValidator(control: FormControl): ValidationErrors {
return !control.value || EMAIL_REGEX.test(control.value) ? null : {email: true};
}
export function EmailValidatorMessage(err, field: FormlyFieldConfig) {
return `"${field.formControl.value}" is not a valid email address`;
}
export function UrlValidator(control: FormControl): ValidationErrors {
return !control.value || URL_REGEX.test(control.value) ? null : {url: true};
}
export function UrlValidatorMessage(err, field: FormlyFieldConfig) {
return `We cannot save "${field.formControl.value}". Please provide the full path, including http:// or https://`;
}
export function PhoneValidator(control: FormControl): ValidationErrors {
return !control.value || PHONE_REGEX.test(control.value) ? null : {phone: true};
}
export function PhoneValidatorMessage(err, field: FormlyFieldConfig) {
return `"${field.formControl.value}" is not a valid phone number`;
}
export function MulticheckboxValidator(control: FormControl): ValidationErrors {
if (control.value) {
for (const key in control.value) {
if (control.value[key] === true) {
return null;
}
}
}
return {required: true};
}
export function MulticheckboxValidatorMessage(err, field: FormlyFieldConfig) {
return 'At least one of these checkboxes must be selected.';
}
export function MinValidationMessage(err, field) {
return `This value should be more than ${field.templateOptions.min}`;
}
export function MaxValidationMessage(err, field) {
return `This value should be less than ${field.templateOptions.max}`;
}
export function ShowError(field: FieldType) {
return field.formControl &&
field.formControl.invalid &&
(
field.formControl.dirty ||
(field.options && field.options.parentForm && field.options.parentForm.submitted) ||
(field.field && field.field.validation && field.field.validation.show)
);
}

View File

@ -1,14 +1,28 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {SessionRedirectComponent} from 'sartography-workflow-lib';
import {ModelerComponent} from './modeler/modeler.component';
import {SignInComponent} from './sign-in/sign-in.component';
import {SignOutComponent} from './sign-out/sign-out.component';
import {WorkflowSpecListComponent} from './workflow-spec-list/workflow-spec-list.component';
const appRoutes: Routes = [
{ path: 'modeler/:workflowSpecId', component: ModelerComponent },
{ path: 'modeler/:workflowSpecId/:fileMetaId', component: ModelerComponent },
{ path: '', component: WorkflowSpecListComponent },
{path: 'modeler/:workflowSpecId', component: ModelerComponent},
{path: 'modeler/:workflowSpecId/:fileMetaId', component: ModelerComponent},
{path: '', component: WorkflowSpecListComponent},
{
path: 'sign-in',
component: SignInComponent
},
{
path: 'sign-out',
component: SignOutComponent
},
{
path: 'session/:token',
component: SessionRedirectComponent
}
];
@NgModule({
@ -18,4 +32,5 @@ const appRoutes: Routes = [
],
exports: [RouterModule]
})
export class AppRoutingModule { }
export class AppRoutingModule {
}

View File

@ -1,11 +1,12 @@
import {HttpClientModule} from '@angular/common/http';
import {NgModule} from '@angular/core';
import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http';
import {Injectable, NgModule} from '@angular/core';
import {FlexLayoutModule} from '@angular/flex-layout';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatDialogModule} from '@angular/material/dialog';
import {MatDividerModule} from '@angular/material/divider';
import {MAT_FORM_FIELD_DEFAULT_OPTIONS} from '@angular/material/form-field';
import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatListModule} from '@angular/material/list';
@ -18,7 +19,7 @@ import {BrowserModule} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {FormlyModule} from '@ngx-formly/core';
import {FormlyMaterialModule} from '@ngx-formly/material';
import {AppEnvironment} from 'sartography-workflow-lib';
import {AppEnvironment, AuthInterceptor} from 'sartography-workflow-lib';
import {environment} from '../environments/environment';
import {DeleteFileDialogComponent} from './_dialogs/delete-file-dialog/delete-file-dialog.component';
import {DeleteWorkflowSpecDialogComponent} from './_dialogs/delete-workflow-spec-dialog/delete-workflow-spec-dialog.component';
@ -26,12 +27,15 @@ import {FileMetaDialogComponent} from './_dialogs/file-meta-dialog/file-meta-dia
import {NewFileDialogComponent} from './_dialogs/new-file-dialog/new-file-dialog.component';
import {OpenFileDialogComponent} from './_dialogs/open-file-dialog/open-file-dialog.component';
import {WorkflowSpecDialogComponent} from './_dialogs/workflow-spec-dialog/workflow-spec-dialog.component';
import {EmailValidator, EmailValidatorMessage, ShowError} from './_forms/validators/formly.validator';
import {GetIconCodePipe} from './_pipes/get-icon-code.pipe';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {DiagramComponent} from './diagram/diagram.component';
import {FileListComponent} from './file-list/file-list.component';
import {ModelerComponent} from './modeler/modeler.component';
import {SignInComponent} from './sign-in/sign-in.component';
import {SignOutComponent} from './sign-out/sign-out.component';
import {WorkflowSpecListComponent} from './workflow-spec-list/workflow-spec-list.component';
export class ThisEnvironment implements AppEnvironment {
@ -41,6 +45,22 @@ export class ThisEnvironment implements AppEnvironment {
irbUrl = environment.irbUrl;
}
@Injectable()
export class AppFormlyConfig {
public static config = {
extras: {
showError: ShowError,
},
validators: [
{name: 'email', validation: EmailValidator},
],
validationMessages: [
{name: 'email', message: EmailValidatorMessage},
{name: 'required', message: 'This field is required.'},
],
};
}
@NgModule({
declarations: [
AppComponent,
@ -53,6 +73,8 @@ export class ThisEnvironment implements AppEnvironment {
ModelerComponent,
NewFileDialogComponent,
OpenFileDialogComponent,
SignInComponent,
SignOutComponent,
WorkflowSpecDialogComponent,
WorkflowSpecListComponent,
],
@ -61,11 +83,7 @@ export class ThisEnvironment implements AppEnvironment {
BrowserModule,
FlexLayoutModule,
FormlyMaterialModule,
FormlyModule.forRoot({
validationMessages: [
{name: 'required', message: 'This field is required'},
],
}),
FormlyModule.forRoot(AppFormlyConfig.config),
FormsModule,
HttpClientModule,
MatButtonModule,
@ -92,7 +110,15 @@ export class ThisEnvironment implements AppEnvironment {
OpenFileDialogComponent,
WorkflowSpecDialogComponent,
],
providers: [{provide: 'APP_ENVIRONMENT', useClass: ThisEnvironment}]
providers: [
{provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {appearance: 'fill'}},
{provide: 'APP_ENVIRONMENT', useClass: ThisEnvironment},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
},
]
})
export class AppModule {
}

View File

@ -0,0 +1,15 @@
<div class="full-height" fxLayout="column" fxLayoutAlign="center center">
<h1>Fake UVA NetBadge Sign In (for testing only)</h1>
<formly-form [fields]="fields" [form]="form" [model]="model">
<mat-error *ngIf="error">{{error}}</mat-error>
<button
(click)="signIn()"
[disabled]="form.invalid"
color="primary"
id="sign_in"
mat-flat-button
>
Sign in
</button>
</formly-form>
</div>

View File

@ -0,0 +1,9 @@
form {
min-width: 150px;
max-width: 500px;
width: 100%;
}
.full-width {
width: 100%;
}

View File

@ -0,0 +1,100 @@
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {FormsModule} from '@angular/forms';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations';
import {ActivatedRoute, convertToParamMap, Router} from '@angular/router';
import {RouterTestingModule} from '@angular/router/testing';
import {FormlyModule} from '@ngx-formly/core';
import {FormlyMaterialModule} from '@ngx-formly/material';
import {of} from 'rxjs';
import {ApiService, MockEnvironment, mockUser} from 'sartography-workflow-lib';
import {EmailValidator, EmailValidatorMessage} from '../_forms/validators/formly.validator';
import {SignInComponent} from './sign-in.component';
describe('SignInComponent', () => {
let component: SignInComponent;
let fixture: ComponentFixture<SignInComponent>;
let httpMock: HttpTestingController;
const mockRouter = {navigate: jasmine.createSpy('navigate')};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SignInComponent],
imports: [
BrowserAnimationsModule,
FormlyModule.forRoot({
validators: [
{name: 'email', validation: EmailValidator},
],
validationMessages: [
{name: 'email', message: EmailValidatorMessage},
],
}),
FormlyMaterialModule,
HttpClientTestingModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
NoopAnimationsModule,
RouterTestingModule,
],
providers: [
ApiService,
{
provide: ActivatedRoute,
useValue: {paramMap: of(convertToParamMap({study_id: '0', workflow_id: '0', task_id: '0'}))}
},
{
provide: Router,
useValue: mockRouter
},
{provide: 'APP_ENVIRONMENT', useClass: MockEnvironment},
],
})
.compileComponents();
}));
beforeEach(() => {
httpMock = TestBed.get(HttpTestingController);
fixture = TestBed.createComponent(SignInComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
httpMock.verify();
fixture.destroy();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should fake sign in during testing', () => {
const openSessionSpy = spyOn((component as any).api, 'openSession').and.stub();
(component as any).environment.production = false;
component.model = mockUser;
component.signIn();
expect(openSessionSpy).toHaveBeenCalledWith(mockUser);
expect(component.error).toBeUndefined();
});
it('should display an error if sign in is called on production', () => {
const openSessionSpy = spyOn((component as any).api, 'openSession').and.stub();
(component as any).environment.production = true;
component.signIn();
expect(openSessionSpy).not.toHaveBeenCalled();
expect(component.error).toBeTruthy();
});
it('should verify the user and redirect to home page on production', () => {
const getUserSpy = spyOn((component as any).api, 'getUser').and.returnValue(of(mockUser));
(component as any).environment.production = true;
(component as any)._redirectOnProduction();
expect(getUserSpy).toHaveBeenCalled();
expect(mockRouter.navigate).toHaveBeenCalledWith(['/']);
});
});

View File

@ -0,0 +1,93 @@
import {Component, Inject, OnInit} from '@angular/core';
import {FormGroup} from '@angular/forms';
import {Router} from '@angular/router';
import {FormlyFieldConfig} from '@ngx-formly/core';
import {ApiService, AppEnvironment, User} from 'sartography-workflow-lib';
@Component({
selector: 'app-sign-in',
templateUrl: './sign-in.component.html',
styleUrls: ['./sign-in.component.scss']
})
export class SignInComponent implements OnInit {
form = new FormGroup({});
model: any = {};
fields: FormlyFieldConfig[] = [
{
key: 'uid',
type: 'input',
defaultValue: 'czn1z',
templateOptions: {
required: true,
label: 'UVA Computing ID',
},
},
{
key: 'email_address',
type: 'input',
defaultValue: 'czn1z@virginia.edu',
templateOptions: {
required: true,
type: 'email',
label: 'UVA Email Address',
},
validators: {validation: ['email']},
},
{
key: 'first_name',
type: 'input',
defaultValue: 'Cordi',
templateOptions: {
label: 'First Name',
},
},
{
key: 'last_name',
type: 'input',
defaultValue: 'Nator',
templateOptions: {
label: 'First Name',
},
},
];
error: Error;
constructor(
@Inject('APP_ENVIRONMENT') private environment: AppEnvironment,
private router: Router,
private api: ApiService
) {
}
ngOnInit() {
this._redirectOnProduction();
}
signIn() {
this.error = undefined;
localStorage.removeItem('token');
// For testing purposes, create a user to simulate login.
if (!this.environment.production) {
this.api.openSession(this.model);
} else {
this.error = new Error('This feature does not work in production.');
}
}
// If this is production, verify the user and redirect to home page.
private _redirectOnProduction() {
if (this.environment.production) {
this.api.getUser().subscribe((user: User) => {
this.router.navigate(['/']);
}, e => {
this.error = e;
localStorage.removeItem('token');
this.router.navigate(['/']);
});
} else {
localStorage.removeItem('token');
}
}
}

View File

@ -0,0 +1,7 @@
<div class="full-height" fxLayout="column" fxLayoutAlign="center center">
<h1>You have been signed out.</h1>
<button
mat-flat-button
color="accent"
(click)="goHome()">Ok</button>
</div>

View File

View File

@ -0,0 +1,49 @@
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {RouterTestingModule} from '@angular/router/testing';
import {ApiService, MockEnvironment} from 'sartography-workflow-lib';
import {SignOutComponent} from './sign-out.component';
describe('SignOutComponent', () => {
let component: SignOutComponent;
let fixture: ComponentFixture<SignOutComponent>;
let httpMock: HttpTestingController;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SignOutComponent],
imports: [
HttpClientTestingModule,
RouterTestingModule,
],
providers: [
ApiService,
{provide: 'APP_ENVIRONMENT', useClass: MockEnvironment},
],
})
.compileComponents();
}));
beforeEach(() => {
httpMock = TestBed.get(HttpTestingController);
fixture = TestBed.createComponent(SignOutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
httpMock.verify();
fixture.destroy();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should go home', () => {
const openUrlSpy = spyOn((component as any).api, 'openUrl').and.stub();
component.goHome();
expect(openUrlSpy).toHaveBeenCalledWith('/');
});
});

View File

@ -0,0 +1,21 @@
import {Component, OnInit} from '@angular/core';
import {ApiService} from 'sartography-workflow-lib';
@Component({
selector: 'app-sign-out',
templateUrl: './sign-out.component.html',
styleUrls: ['./sign-out.component.scss']
})
export class SignOutComponent implements OnInit {
constructor(private api: ApiService) {
localStorage.removeItem('token');
}
ngOnInit() {
}
goHome() {
this.api.openUrl('/');
}
}