From f9a47e36f7e42fb57bceea6b02d7efd3acc57768 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 24 Feb 2020 14:19:20 -0500 Subject: [PATCH 1/4] Adds fake sign-in and sign-out components --- src/app/_forms/validators/email.regex.ts | 4 + .../_forms/validators/email.validator.spec.ts | 36 +++++ src/app/_forms/validators/email.validator.ts | 9 ++ .../validators/formly.validator.spec.ts | 142 ++++++++++++++++++ src/app/_forms/validators/formly.validator.ts | 62 ++++++++ src/app/app-routing.module.ts | 27 +++- src/app/app.module.ts | 44 ++++-- src/app/sign-in/sign-in.component.html | 15 ++ src/app/sign-in/sign-in.component.scss | 9 ++ src/app/sign-in/sign-in.component.spec.ts | 100 ++++++++++++ src/app/sign-in/sign-in.component.ts | 93 ++++++++++++ src/app/sign-out/sign-out.component.html | 7 + src/app/sign-out/sign-out.component.scss | 0 src/app/sign-out/sign-out.component.spec.ts | 49 ++++++ src/app/sign-out/sign-out.component.ts | 21 +++ 15 files changed, 603 insertions(+), 15 deletions(-) create mode 100644 src/app/_forms/validators/email.regex.ts create mode 100644 src/app/_forms/validators/email.validator.spec.ts create mode 100644 src/app/_forms/validators/email.validator.ts create mode 100644 src/app/_forms/validators/formly.validator.spec.ts create mode 100644 src/app/_forms/validators/formly.validator.ts create mode 100644 src/app/sign-in/sign-in.component.html create mode 100644 src/app/sign-in/sign-in.component.scss create mode 100644 src/app/sign-in/sign-in.component.spec.ts create mode 100644 src/app/sign-in/sign-in.component.ts create mode 100644 src/app/sign-out/sign-out.component.html create mode 100644 src/app/sign-out/sign-out.component.scss create mode 100644 src/app/sign-out/sign-out.component.spec.ts create mode 100644 src/app/sign-out/sign-out.component.ts diff --git a/src/app/_forms/validators/email.regex.ts b/src/app/_forms/validators/email.regex.ts new file mode 100644 index 0000000..b93a261 --- /dev/null +++ b/src/app/_forms/validators/email.regex.ts @@ -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; diff --git a/src/app/_forms/validators/email.validator.spec.ts b/src/app/_forms/validators/email.validator.spec.ts new file mode 100644 index 0000000..4bf4e75 --- /dev/null +++ b/src/app/_forms/validators/email.validator.spec.ts @@ -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(); + } + }); + +}); diff --git a/src/app/_forms/validators/email.validator.ts b/src/app/_forms/validators/email.validator.ts new file mode 100644 index 0000000..247255d --- /dev/null +++ b/src/app/_forms/validators/email.validator.ts @@ -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; + } +} diff --git a/src/app/_forms/validators/formly.validator.spec.ts b/src/app/_forms/validators/formly.validator.spec.ts new file mode 100644 index 0000000..aaf1dec --- /dev/null +++ b/src/app/_forms/validators/formly.validator.spec.ts @@ -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(); + }); +}); diff --git a/src/app/_forms/validators/formly.validator.ts b/src/app/_forms/validators/formly.validator.ts new file mode 100644 index 0000000..9131a03 --- /dev/null +++ b/src/app/_forms/validators/formly.validator.ts @@ -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) + ); +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 9f2844c..4b4a84b 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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 { +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 614ef30..95c5ad7 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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 { } diff --git a/src/app/sign-in/sign-in.component.html b/src/app/sign-in/sign-in.component.html new file mode 100644 index 0000000..427ead6 --- /dev/null +++ b/src/app/sign-in/sign-in.component.html @@ -0,0 +1,15 @@ +
+

Fake UVA NetBadge Sign In (for testing only)

+ + {{error}} + + +
diff --git a/src/app/sign-in/sign-in.component.scss b/src/app/sign-in/sign-in.component.scss new file mode 100644 index 0000000..4b95763 --- /dev/null +++ b/src/app/sign-in/sign-in.component.scss @@ -0,0 +1,9 @@ +form { + min-width: 150px; + max-width: 500px; + width: 100%; +} + +.full-width { + width: 100%; +} diff --git a/src/app/sign-in/sign-in.component.spec.ts b/src/app/sign-in/sign-in.component.spec.ts new file mode 100644 index 0000000..9c507a1 --- /dev/null +++ b/src/app/sign-in/sign-in.component.spec.ts @@ -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; + 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(['/']); + }); +}); diff --git a/src/app/sign-in/sign-in.component.ts b/src/app/sign-in/sign-in.component.ts new file mode 100644 index 0000000..e4ee66d --- /dev/null +++ b/src/app/sign-in/sign-in.component.ts @@ -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'); + } + } +} diff --git a/src/app/sign-out/sign-out.component.html b/src/app/sign-out/sign-out.component.html new file mode 100644 index 0000000..dcec1dc --- /dev/null +++ b/src/app/sign-out/sign-out.component.html @@ -0,0 +1,7 @@ +
+

You have been signed out.

+ +
diff --git a/src/app/sign-out/sign-out.component.scss b/src/app/sign-out/sign-out.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/sign-out/sign-out.component.spec.ts b/src/app/sign-out/sign-out.component.spec.ts new file mode 100644 index 0000000..7837fed --- /dev/null +++ b/src/app/sign-out/sign-out.component.spec.ts @@ -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; + 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('/'); + }); +}); diff --git a/src/app/sign-out/sign-out.component.ts b/src/app/sign-out/sign-out.component.ts new file mode 100644 index 0000000..8d1a929 --- /dev/null +++ b/src/app/sign-out/sign-out.component.ts @@ -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('/'); + } +} From fe1a481f43380aff3c8848d01f0774bb3bb0bf08 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 24 Feb 2020 15:20:18 -0500 Subject: [PATCH 2/4] Adds navbar, footer, and Material theme --- angular.json | 6 +- package-lock.json | 6 +- package.json | 2 +- src/_config.scss | 67 +++++ src/_material.scss | 92 +++++++ src/app/_forms/validators/phone.regex.ts | 4 + src/app/_forms/validators/url.regex.ts | 23 ++ src/app/_forms/validators/url.validator.ts | 31 +++ src/app/app-routing.module.ts | 25 +- src/app/app.component.html | 6 +- src/app/app.component.ts | 4 +- src/app/app.module.ts | 9 +- src/app/footer/footer.component.html | 3 + src/app/footer/footer.component.scss | 8 + src/app/footer/footer.component.spec.ts | 25 ++ src/app/footer/footer.component.ts | 15 ++ src/app/home/home.component.html | 2 + src/app/home/home.component.scss | 0 src/app/home/home.component.spec.ts | 25 ++ src/app/home/home.component.ts | 15 ++ src/app/navbar/navbar.component.html | 42 ++++ src/app/navbar/navbar.component.scss | 35 +++ src/app/navbar/navbar.component.spec.ts | 56 +++++ src/app/navbar/navbar.component.ts | 67 +++++ src/material-theme.scss | 278 +++++++++++++++++++++ src/{styles.css => styles.scss} | 14 +- 26 files changed, 831 insertions(+), 29 deletions(-) create mode 100644 src/_config.scss create mode 100644 src/_material.scss create mode 100644 src/app/_forms/validators/phone.regex.ts create mode 100644 src/app/_forms/validators/url.regex.ts create mode 100644 src/app/_forms/validators/url.validator.ts create mode 100644 src/app/footer/footer.component.html create mode 100644 src/app/footer/footer.component.scss create mode 100644 src/app/footer/footer.component.spec.ts create mode 100644 src/app/footer/footer.component.ts create mode 100644 src/app/home/home.component.html create mode 100644 src/app/home/home.component.scss create mode 100644 src/app/home/home.component.spec.ts create mode 100644 src/app/home/home.component.ts create mode 100644 src/app/navbar/navbar.component.html create mode 100644 src/app/navbar/navbar.component.scss create mode 100644 src/app/navbar/navbar.component.spec.ts create mode 100644 src/app/navbar/navbar.component.ts create mode 100644 src/material-theme.scss rename src/{styles.css => styles.scss} (54%) diff --git a/angular.json b/angular.json index 2b3a0b4..c02d3cd 100644 --- a/angular.json +++ b/angular.json @@ -26,10 +26,9 @@ "src/assets" ], "styles": [ - "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "node_modules/bpmn-js/dist/assets/diagram-js.css", "node_modules/bpmn-js/dist/assets/bpmn-font/css/bpmn.css", - "src/styles.css" + "src/styles.scss" ], "scripts": [] }, @@ -145,8 +144,7 @@ "tsConfig": "src/tsconfig.spec.json", "karmaConfig": "karma.conf.js", "styles": [ - "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", - "src/styles.css" + "src/styles.scss" ], "scripts": [], "assets": [ diff --git a/package-lock.json b/package-lock.json index 4ec68d3..dea8929 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11641,9 +11641,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sartography-workflow-lib": { - "version": "0.0.45", - "resolved": "https://registry.npmjs.org/sartography-workflow-lib/-/sartography-workflow-lib-0.0.45.tgz", - "integrity": "sha512-0c3XT5ID0gF8SM18DmD88jDwOccjaViEhJ6DWzh9irblPmvbeA/B7a1MS0i0fm6IC3pw7tdja9pBFeDXz0UOZw==", + "version": "0.0.46", + "resolved": "https://registry.npmjs.org/sartography-workflow-lib/-/sartography-workflow-lib-0.0.46.tgz", + "integrity": "sha512-gxm3mfSyQ/g9Axen4w0HAH+s3mQARVANN5KfPQZgSnvT1wP/ymnhZDikg/hfbUJQZ5qwMv+phKK53HeAsexU3w==", "requires": { "tslib": "^1.9.0" } diff --git a/package.json b/package.json index dc8f5a8..cef6b7e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "file-saver": "^2.0.2", "hammerjs": "^2.0.8", "rxjs": "~6.5.4", - "sartography-workflow-lib": "^0.0.45", + "sartography-workflow-lib": "^0.0.46", "tslib": "^1.10.0", "uuid": "^3.4.0", "zone.js": "~0.9.1" diff --git a/src/_config.scss b/src/_config.scss new file mode 100644 index 0000000..6293ca1 --- /dev/null +++ b/src/_config.scss @@ -0,0 +1,67 @@ +// GLOBAL SCSS VARIABLES +$body-font-family: '"franklin-gothic-urw", sans-serif'; +$heading-font-family: '"franklin-gothic-urw-cond", sans-serif'; + +// COLOR PALETTE + +// gray +$brand-gray: #4e4e4e; +$brand-gray-tint-1: #666666; +$brand-gray-tint-2: #DADADA; +$brand-gray-tint-3: #F1F1EF; +$brand-gray-tint-4: scale-color($brand-gray, $lightness: 90%); +$body-color: $brand-gray; +$body-color-muted: $brand-gray-tint-1; +$body-color-light: $brand-gray-tint-4; +$brand-gray-muted: $brand-gray-tint-1; +$brand-gray-light: $brand-gray-tint-4; + +// primary (UVA "Jefferson Blue") +$brand-primary: #232D4B; +$brand-primary-tint-1: #394E79; +$brand-primary-tint-2: #6C799C; +$brand-primary-tint-3: #A9AFC7; +$brand-primary-tint-4: scale-color($brand-primary, $lightness: 90%); +$brand-primary-shade-1: #092255; +$brand-primary-shade-2: #041D4F; +$brand-primary-shade-3: #02194A; +$brand-primary-shade-4: #021745; +$brand-primary-shade-5: #03143E; +$brand-primary-muted: $brand-primary-tint-1; +$brand-primary-light: $brand-primary-tint-4; + +// accent (UVA "Rotunda Orange") +$brand-accent: #E57200; +$brand-accent-tint-1: #F69350; +$brand-accent-tint-2: #FAB584; +$brand-accent-tint-3: #FDD8BB; +$brand-accent-tint-4: scale-color($brand-accent, $lightness: 90%); +$brand-accent-shade-1: #E76E25; +$brand-accent-shade-2: #DD6923; +$brand-accent-shade-3: #D36421; +$brand-accent-shade-4: #C8601F; +$brand-accent-shade-5: #C05B1D; +$brand-accent-muted: $brand-accent-tint-1; +$brand-accent-light: $brand-accent-tint-4; + +// warning (UVA "Emergency Red") +$brand-warning: #DF1E43; +$brand-warning-muted: desaturate(scale-color($brand-warning, $lightness: 30%), 20%); +$brand-warning-light: scale-color($brand-warning, $lightness: 90%); +$easeDuration: 300ms; +$animationDuration: 500ms; +$tile-height-1x: 226px; +$tile-height-2x: 288px; +$uva-header-height: 40px; +$site-header-height: 64px; +$search-bar-height-sm: 64px; +$search-bar-height-md: 128px; +$search-bar-height-lg: 64px; +$header-height-sm: ($uva-header-height + $site-header-height + $search-bar-height-sm); +$header-height-md: ($uva-header-height + $site-header-height + $search-bar-height-md); +$header-height-lg: ($uva-header-height + $site-header-height + $search-bar-height-lg); + +// Green +$brand-green: #64B343; +$brand-green-muted: #8EC774; +$brand-green-light: #B5D9A3; diff --git a/src/_material.scss b/src/_material.scss new file mode 100644 index 0000000..fc6a68a --- /dev/null +++ b/src/_material.scss @@ -0,0 +1,92 @@ +@import 'config'; +@import '../node_modules/@angular/material/theming'; + +// Define a custom typography config that overrides the font-family +$custom-typography: mat-typography-config( + $font-family: $body-font-family, + $display-4: mat-typography-level(2.500rem, 1.0, 700, $heading-font-family), + $display-3: mat-typography-level(2.250rem, 1.0, 700, $heading-font-family), + $display-2: mat-typography-level(2.000rem, 1.0, 700, $heading-font-family), + $display-1: mat-typography-level(1.750rem, 1.0, 700, $heading-font-family), + $headline: mat-typography-level(48px, 1.0, 700, $heading-font-family), + $title: mat-typography-level(1.500rem, 1.0, 700, $body-font-family), + $subheading-2: mat-typography-level(1.375rem, 1.0, 500, $body-font-family), + $subheading-1: mat-typography-level(1.250rem, 1.0, 500, $body-font-family), + $body-2: mat-typography-level(1.000rem, 1.5, 500, $body-font-family), + $body-1: mat-typography-level(1.000rem, 1.5, 500, $body-font-family), + $caption: mat-typography-level(1.000rem, 1.5, 500, $body-font-family), + $button: mat-typography-level(1.000rem, 1.5, 500, $body-font-family), + $input: mat-typography-level(1.000rem, 1.5, 500, $body-font-family) +); + +$mat-blue: ( + 50: #f1f5f7, + 100: #b3c1d3, + 200: $brand-primary-tint-3, + 300: $brand-primary-tint-2, + 400: $brand-primary-tint-1, + 500: $brand-primary, + 600: $brand-primary-shade-1, + 700: $brand-primary-shade-2, + 800: $brand-primary-shade-3, + 900: $brand-primary-shade-4, + A100: #b3c1d3, + A200: $brand-primary-tint-3, + A400: $brand-primary-tint-2, + A700: $brand-primary-tint-1, + contrast: ( + 50: $body-color, + 100: $body-color, + 200: $body-color, + 300: #ffffff, + 400: #ffffff, + 500: #ffffff, + 600: #ffffff, + 700: #ffffff, + 800: #ffffff, + 900: #ffffff, + A100: $body-color, + A200: #ffffff, + A400: #ffffff, + A700: #ffffff, + ) +); + +$mat-orange: ( + 50: #fceee0, + 100: $brand-accent-tint-3, + 200: $brand-accent-tint-2, + 300: $brand-accent-tint-1, + 400: $brand-accent, + 500: $brand-accent-shade-1, + 600: $brand-accent-shade-2, + 700: $brand-accent-shade-3, + 800: $brand-accent-shade-4, + 900: $brand-accent-shade-5, + A100: #fceee0, + A200: $brand-accent-tint-3, + A400: $brand-accent-tint-2, + A700: $brand-accent-tint-1, + contrast: ( + 50: $body-color, + 100: $body-color, + 200: $body-color, + 300: $body-color, + 400: $body-color, + 500: #ffffff, + 600: #ffffff, + 700: #ffffff, + 800: #ffffff, + 900: #ffffff, + A100: $body-color, + A200: $body-color, + A400: $body-color, + A700: $body-color, + ) +); + +$cr-connect-primary: mat-palette($mat-blue); +$cr-connect-accent: mat-palette($mat-orange); +$cr-connect-warn: mat-palette($mat-red); +$cr-connect-theme: mat-light-theme($cr-connect-primary, $cr-connect-accent, $cr-connect-warn); + diff --git a/src/app/_forms/validators/phone.regex.ts b/src/app/_forms/validators/phone.regex.ts new file mode 100644 index 0000000..f55868a --- /dev/null +++ b/src/app/_forms/validators/phone.regex.ts @@ -0,0 +1,4 @@ +// tslint:disable-next-line:max-line-length +export const PHONE_REGEX = /^(?:\+?1\s*(?:[.-]\s*)?)?(?:\(\s*([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\s*\)|([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\s*(?:[.-]\s*)?([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\s*(?:[.-]\s*)?([0-9]{4})(?:\s*(?:#|x\.?|ext\.?|extension)\s*(\d+))?$/i; + +export default PHONE_REGEX; diff --git a/src/app/_forms/validators/url.regex.ts b/src/app/_forms/validators/url.regex.ts new file mode 100644 index 0000000..dfbddf9 --- /dev/null +++ b/src/app/_forms/validators/url.regex.ts @@ -0,0 +1,23 @@ +export const URL_REGEX = new RegExp( + '^' + + '(?:(?:https?|ftp)://)' + + '(?:\\S+(?::\\S*)?@)?' + + '(?:' + + '(?!(?:10|127)(?:\\.\\d{1,3}){3})' + + '(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' + + '(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' + + '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' + + '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' + + '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' + + '|' + + '(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' + + '(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' + + '(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' + + '\\.?' + + ')' + + '(?::\\d{2,5})?' + + '(?:[/?#]\\S*)?' + + '$', 'i' + ); + +export default URL_REGEX; diff --git a/src/app/_forms/validators/url.validator.ts b/src/app/_forms/validators/url.validator.ts new file mode 100644 index 0000000..f6d3641 --- /dev/null +++ b/src/app/_forms/validators/url.validator.ts @@ -0,0 +1,31 @@ +import { AbstractControl, ValidationErrors } from '@angular/forms'; + +export function ValidateUrl(control: AbstractControl): ValidationErrors { + + const urlRegEx = new RegExp( + '^' + + '(?:(?:https?|ftp)://)' + + '(?:\\S+(?::\\S*)?@)?' + + '(?:' + + '(?!(?:10|127)(?:\\.\\d{1,3}){3})' + + '(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' + + '(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' + + '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' + + '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' + + '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' + + '|' + + '(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' + + '(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' + + '(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' + + '\\.?' + + ')' + + '(?::\\d{2,5})?' + + '(?:[/?#]\\S*)?' + + '$', 'i' + ); + + if (!urlRegEx.test(control.value) && control.value && control.value !== '') { + const error: ValidationErrors = { url: true }; + return error; + } +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 4b4a84b..e525627 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,16 +1,25 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {SessionRedirectComponent} from 'sartography-workflow-lib'; +import {HomeComponent} from './home/home.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'; -const appRoutes: Routes = [ - {path: 'modeler/:workflowSpecId', component: ModelerComponent}, - {path: 'modeler/:workflowSpecId/:fileMetaId', component: ModelerComponent}, - {path: '', component: WorkflowSpecListComponent}, +const routes: Routes = [ + { + path: '', + component: HomeComponent + }, + { + path: 'modeler/:workflowSpecId', + component: ModelerComponent + }, + { + path: 'modeler/:workflowSpecId/:fileMetaId', + component: ModelerComponent + }, { path: 'sign-in', component: SignInComponent @@ -28,7 +37,11 @@ const appRoutes: Routes = [ @NgModule({ declarations: [], imports: [ - RouterModule.forRoot(appRoutes) + RouterModule.forRoot(routes, { + scrollPositionRestoration: 'enabled', + anchorScrolling: 'enabled', + scrollOffset: [0, 84], + }) ], exports: [RouterModule] }) diff --git a/src/app/app.component.html b/src/app/app.component.html index 0680b43..c00c62a 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1 +1,5 @@ - +
+ + + +
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6ff07e1..bccfc9b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,5 @@ -import { Component } from '@angular/core'; +import {Component} from '@angular/core'; +import {isSignedIn} from 'sartography-workflow-lib'; @Component({ selector: 'app-root', @@ -7,4 +8,5 @@ import { Component } from '@angular/core'; }) export class AppComponent { title = 'CR Connect Configuration'; + isSignedIn = isSignedIn; } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 95c5ad7..9786fec 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -19,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, AuthInterceptor} from 'sartography-workflow-lib'; +import {AppEnvironment, AuthInterceptor, SessionRedirectComponent} 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'; @@ -33,10 +33,13 @@ 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 {FooterComponent} from './footer/footer.component'; import {ModelerComponent} from './modeler/modeler.component'; +import {NavbarComponent} from './navbar/navbar.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'; +import { HomeComponent } from './home/home.component'; export class ThisEnvironment implements AppEnvironment { production = environment.production; @@ -69,14 +72,18 @@ export class AppFormlyConfig { DiagramComponent, FileListComponent, FileMetaDialogComponent, + FooterComponent, GetIconCodePipe, ModelerComponent, + NavbarComponent, NewFileDialogComponent, OpenFileDialogComponent, + SessionRedirectComponent, SignInComponent, SignOutComponent, WorkflowSpecDialogComponent, WorkflowSpecListComponent, + HomeComponent, ], imports: [ BrowserAnimationsModule, diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html new file mode 100644 index 0000000..f01d428 --- /dev/null +++ b/src/app/footer/footer.component.html @@ -0,0 +1,3 @@ +
+ CR Connect - University of Virginia +
diff --git a/src/app/footer/footer.component.scss b/src/app/footer/footer.component.scss new file mode 100644 index 0000000..da58409 --- /dev/null +++ b/src/app/footer/footer.component.scss @@ -0,0 +1,8 @@ +@import "../../_config.scss"; + +footer { + background-color: $brand-gray; + color: white; + text-align: center; + padding: 20px; +} diff --git a/src/app/footer/footer.component.spec.ts b/src/app/footer/footer.component.spec.ts new file mode 100644 index 0000000..2ca6c45 --- /dev/null +++ b/src/app/footer/footer.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FooterComponent } from './footer.component'; + +describe('FooterComponent', () => { + let component: FooterComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FooterComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts new file mode 100644 index 0000000..da17d82 --- /dev/null +++ b/src/app/footer/footer.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'] +}) +export class FooterComponent implements OnInit { + + constructor() { } + + ngOnInit() { + } + +} diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html new file mode 100644 index 0000000..8c5bd4a --- /dev/null +++ b/src/app/home/home.component.html @@ -0,0 +1,2 @@ + + diff --git a/src/app/home/home.component.scss b/src/app/home/home.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts new file mode 100644 index 0000000..490e81b --- /dev/null +++ b/src/app/home/home.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HomeComponent } from './home.component'; + +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ HomeComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts new file mode 100644 index 0000000..6a863cd --- /dev/null +++ b/src/app/home/home.component.ts @@ -0,0 +1,15 @@ +import {Component} from '@angular/core'; +import {isSignedIn} from 'sartography-workflow-lib'; + +@Component({ + selector: 'app-home', + templateUrl: './home.component.html', + styleUrls: ['./home.component.scss'] +}) +export class HomeComponent { + isSignedIn = isSignedIn; + + constructor() { + } + +} diff --git a/src/app/navbar/navbar.component.html b/src/app/navbar/navbar.component.html new file mode 100644 index 0000000..23a137f --- /dev/null +++ b/src/app/navbar/navbar.component.html @@ -0,0 +1,42 @@ + diff --git a/src/app/navbar/navbar.component.scss b/src/app/navbar/navbar.component.scss new file mode 100644 index 0000000..6c4b6c3 --- /dev/null +++ b/src/app/navbar/navbar.component.scss @@ -0,0 +1,35 @@ +@import "../../config"; + +.flex-spacer { + flex-grow: 1; +} + +nav { + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 8px 16px; + background-color: $brand-primary; + color: white; + height: 48px; + + .site-title { + color: white; + font-size: 1.5rem; + line-height: 48px; + margin-bottom: 0; + background-color: $brand-primary; + text-transform: uppercase; + } +} + +a.active { + background-color: $brand-primary-tint-1; + color: white; +} + +.mat-menu-item.active { + background-color: $brand-gray-tint-2; + color: $brand-gray; + font-weight: bold; +} diff --git a/src/app/navbar/navbar.component.spec.ts b/src/app/navbar/navbar.component.spec.ts new file mode 100644 index 0000000..c38dabf --- /dev/null +++ b/src/app/navbar/navbar.component.spec.ts @@ -0,0 +1,56 @@ +import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import {MatIconModule} from '@angular/material/icon'; +import {MatMenuModule} from '@angular/material/menu'; +import {Router} from '@angular/router'; +import {RouterTestingModule} from '@angular/router/testing'; +import {ApiService, MockEnvironment, mockUser} from 'sartography-workflow-lib'; + +import { NavbarComponent } from './navbar.component'; + +describe('NavbarComponent', () => { + let component: NavbarComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + const mockRouter = {navigate: jasmine.createSpy('navigate')}; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + NavbarComponent + ], + imports: [ + HttpClientTestingModule, + MatIconModule, + MatMenuModule, + RouterTestingModule, + ], + providers: [ + ApiService, + { + provide: Router, + useValue: mockRouter + }, + {provide: 'APP_ENVIRONMENT', useClass: MockEnvironment}, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + localStorage.setItem('token', 'some_token'); + httpMock = TestBed.get(HttpTestingController); + fixture = TestBed.createComponent(NavbarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + const uReq = httpMock.expectOne('apiRoot/user'); + expect(uReq.request.method).toEqual('GET'); + uReq.flush(mockUser); + expect(component.user).toEqual(mockUser); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts new file mode 100644 index 0000000..6eade1a --- /dev/null +++ b/src/app/navbar/navbar.component.ts @@ -0,0 +1,67 @@ +import {Component, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; +import {ApiService, isSignedIn, User} from 'sartography-workflow-lib'; + +interface NavItem { + path?: string; + id: string; + label: string; + icon?: string; + links?: NavItem[]; + action?: () => void; +} + +@Component({ + selector: 'app-navbar', + templateUrl: './navbar.component.html', + styleUrls: ['./navbar.component.scss'] +}) +export class NavbarComponent implements OnInit { + navLinks: NavItem[]; + user: User; + isSignedIn = isSignedIn; + + constructor( + private router: Router, + private api: ApiService, + ) { + this._loadUser(); + } + + ngOnInit() { + } + + isLinkActive(path: string) { + return path === this.router.url; + } + + private _loadUser() { + if (isSignedIn()) { + this.api.getUser().subscribe(u => { + this.user = u; + this._loadNavLinks(); + }, error => { + localStorage.removeItem('token'); + this.api.openUrl('/'); + }); + } + } + + private _loadNavLinks() { + const displayName = this.user.display_name || this.user.first_name || this.user.last_name; + this.navLinks = [ + {path: '/', id: 'nav_home', label: 'Home'}, + {path: '/inbox', id: 'nav_inbox', label: 'Inbox'}, + {path: '/help', id: 'nav_help', label: 'Help'}, + { + id: 'nav_account', label: `${displayName} (${this.user.email_address})`, + icon: 'account_circle', + links: [ + {path: '/profile', id: 'nav_profile', label: 'Profile', icon: 'person'}, + {path: '/notifications', id: 'nav_notifications', label: 'Notifications', icon: 'notifications'}, + {path: '/sign-out', id: 'nav_sign_out', label: 'Sign out', icon: 'exit_to_app'}, + ] + } + ]; + } +} diff --git a/src/material-theme.scss b/src/material-theme.scss new file mode 100644 index 0000000..36d59eb --- /dev/null +++ b/src/material-theme.scss @@ -0,0 +1,278 @@ +@import "material"; + +@include mat-core($custom-typography); +@include angular-material-theme($cr-connect-theme); + +@mixin cr-connect-theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + + h1 { + text-transform: uppercase; + color: $brand-accent; + } + + html, body { + height: 100%; + margin: 0; + padding: 0; + font-family: Roboto, "Helvetica Neue", sans-serif; + } + + body { + margin: 0; + padding-top: 64px; + } + + mat-radio-button, .mat-checkbox { + padding-right: 16px; + } + + formly-field mat-form-field { + margin-bottom: 2rem; + } + + formly-field.textarea-cols { + display: flow-root; + + & > formly-wrapper-mat-form-field > mat-form-field { + width: auto !important; + + .mat-form-field-infix { + width: auto !important; + } + } + } + + formly-field.read-only { + .mat-form-field-outline { + background-color: $brand-gray-tint-2; + border-radius: 5px; + } + } + + formly-field.vertical-radio-group { + mat-radio-button { + margin: 5px; + padding-right: 16px; + display: block; + + label.mat-radio-label { + white-space: normal; + } + } + } + + formly-field.vertical-checkbox-group { + .mat-checkbox { + margin: 5px; + padding-right: 16px; + display: block; + + .mat-checkbox-layout { + white-space: normal; + } + } + } + + .full-height { + width: 100%; + height: calc(100vh - 64px); + } + + .container, .row { + padding: 1em; + } + + //.container { + // display: grid; + // justify-content: center; + // justify-items: center; + // grid-template-columns: 1fr; + // position: relative; + // max-width: 100vw; + // + // .row { + // margin-top: 4em; + // margin-bottom: 4em; + // position: relative; + // + // max-width: 100vw; + // @media (min-width: 576px) { + // max-width: calc(100% - 40px); + // } + // @media (min-width: 768px) { + // max-width: calc(100% - 80px); + // } + // @media (min-width: 992px) { + // max-width: calc(100% - 100px); + // } + // @media (min-width: 1200px) { + // max-width: calc(100% - 120px); + // } + // } + //} + + button { + &.btn-xl { + font-size: 24px; + padding-left: 24px; + padding-right: 24px; + padding-top: 8px; + padding-bottom: 8px; + } + + &.btn-lg { + font-size: 20px; + padding-left: 20px; + padding-right: 20px; + padding-top: 6px; + padding-bottom: 6px; + } + + &.disabled { + background-color: $brand-gray !important; + } + } + + mat-form-field.mat-form-field { + font-size: 16px; + } + + .mat-form-field-wrapper .mat-form-field-subscript-wrapper { + position: static; + } + + .alert { + padding: 2em; + text-align: center; + border-radius: 4px; + margin-bottom: 2em; + + &.alert-info { + background-color: $brand-primary-light; + color: black; + } + + &.alert-warn { + background-color: $brand-warning; + color: white; + } + } + + .mat-drawer .mat-nav-list .mat-list-item { + min-width: 320px; + } + + .mat-drawer .mat-nav-list .mat-list-item.active { + background-color: $brand-primary; + color: white; + } + + .pad-0 { + padding: 0px; + } + .pad-1 { + padding: 1em; + } + .pad-2 { + padding: 2em; + } + .pad-3 { + padding: 3em; + } + .pad-4 { + padding: 4em; + } + .pad-5 { + padding: 5em; + } + .pad-6 { + padding: 6em; + } + .pad-7 { + padding: 7em; + } + .pad-8 { + padding: 8em; + } + + .margin-top-none, .row.margin-top-none { + margin-top: 0px !important; + } + .margin-bottom-none, .row.margin-bottom-none { + margin-bottom: 0px !important; + } + .ghost { + opacity: 0; + } + + // XS + @media (max-width: 575px) { + .cdk-overlay-wrapper .cdk-overlay-pane { + width: 100%; + height: 100%; + } + + mat-dialog-container.mat-dialog-container { + border-radius: 0; + } + } + + // SM + @media (min-width: 576px) { + .cdk-overlay-wrapper .cdk-overlay-pane { + width: 90%; + height: 90%; + } + + mat-dialog-container.mat-dialog-container { + width: 100vw; + } + } + + // MD + @media (min-width: 768px) { + .cdk-overlay-wrapper .cdk-overlay-pane { + width: 75%; + height: 75%; + } + + mat-dialog-container.mat-dialog-container { + width: 90vw; + } + } + + // LG + @media (min-width: 992px) { + .cdk-overlay-wrapper .cdk-overlay-pane { + width: 75%; + height: 75%; + } + + mat-dialog-container.mat-dialog-container { + width: 80vw; + } + } + + // XL + @media (min-width: 1200px) { + .cdk-overlay-wrapper .cdk-overlay-pane { + width: 75%; + height: 75%; + } + + mat-dialog-container.mat-dialog-container { + width: 70vw; + } + } + + .mat-dialog-content[mat-dialog-content] { + max-height: 85vh; + } + + .loading { + height: calc(100vh - 64px); + } + +} diff --git a/src/styles.css b/src/styles.scss similarity index 54% rename from src/styles.css rename to src/styles.scss index 11dcd9d..f0a98fc 100644 --- a/src/styles.css +++ b/src/styles.scss @@ -1,16 +1,6 @@ -@import '~@angular/material/prebuilt-themes/indigo-pink.css'; @import '~bpmn-js/dist/assets/diagram-js.css'; @import '~bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css'; @import '~bpmn-js-properties-panel/dist/assets/bpmn-js-properties-panel.css'; @import '~diagram-js-minimap/assets/diagram-js-minimap.css'; - -html, body { - height: 100%; - margin: 0; - padding: 0; - font-family: Roboto, "Helvetica Neue", sans-serif; -} - -.container, .row { - padding: 1em; -} +@import './material-theme.scss'; +@include cr-connect-theme($cr-connect-theme); From d39e41496297c053623aabdfc064e494b4361da5 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 24 Feb 2020 16:43:03 -0500 Subject: [PATCH 3/4] Fixes broken unit tests. Updates sartography-workflow-lib. --- package-lock.json | 6 +++--- package.json | 2 +- src/app/app.component.scss | 7 +++++++ src/app/app.component.spec.ts | 12 ++++++++++- src/app/home/home.component.spec.ts | 31 +++++++++++++++++++++++++--- src/app/sign-in/sign-in.component.ts | 5 ++++- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index dea8929..b7efd61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11641,9 +11641,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sartography-workflow-lib": { - "version": "0.0.46", - "resolved": "https://registry.npmjs.org/sartography-workflow-lib/-/sartography-workflow-lib-0.0.46.tgz", - "integrity": "sha512-gxm3mfSyQ/g9Axen4w0HAH+s3mQARVANN5KfPQZgSnvT1wP/ymnhZDikg/hfbUJQZ5qwMv+phKK53HeAsexU3w==", + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/sartography-workflow-lib/-/sartography-workflow-lib-0.0.48.tgz", + "integrity": "sha512-HeqzSbbt5J9S3FulBCUEYogXIRPkkTwnhViaxd6zEzpMDebZEt6FMCBXjVZ8e+x0griL66RNCvWzh4EXRmOkqA==", "requires": { "tslib": "^1.9.0" } diff --git a/package.json b/package.json index cef6b7e..b40d600 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "file-saver": "^2.0.2", "hammerjs": "^2.0.8", "rxjs": "~6.5.4", - "sartography-workflow-lib": "^0.0.46", + "sartography-workflow-lib": "^0.0.48", "tslib": "^1.10.0", "uuid": "^3.4.0", "zone.js": "~0.9.1" diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29..e25ee2c 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,7 @@ +#globalHeader { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 2; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index b10f88d..890d34b 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,8 +1,12 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatIconModule} from '@angular/material/icon'; +import {MatMenuModule} from '@angular/material/menu'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {RouterTestingModule} from '@angular/router/testing'; import {AppComponent} from './app.component'; +import {FooterComponent} from './footer/footer.component'; +import {NavbarComponent} from './navbar/navbar.component'; describe('AppComponent', () => { let component: AppComponent; @@ -10,9 +14,15 @@ describe('AppComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [AppComponent], + declarations: [ + AppComponent, + FooterComponent, + NavbarComponent + ], imports: [ BrowserAnimationsModule, + MatIconModule, + MatMenuModule, RouterTestingModule, ] }) diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts index 490e81b..027c50a 100644 --- a/src/app/home/home.component.spec.ts +++ b/src/app/home/home.component.spec.ts @@ -1,6 +1,21 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import {Component, NO_ERRORS_SCHEMA} from '@angular/core'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MockEnvironment} from 'sartography-workflow-lib'; -import { HomeComponent } from './home.component'; +import {HomeComponent} from './home.component'; + + +@Component({ + selector: 'app-sign-in', + template: '' +}) +class MockSignInComponent {} + +@Component({ + selector: 'app-workflow-spec-list', + template: '' +}) +class MockWorkflowSpecListComponent {} describe('HomeComponent', () => { let component: HomeComponent; @@ -8,7 +23,11 @@ describe('HomeComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ HomeComponent ] + declarations: [ + HomeComponent, + MockSignInComponent, + MockWorkflowSpecListComponent, + ], }) .compileComponents(); })); @@ -22,4 +41,10 @@ describe('HomeComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should check signed-in state', () => { + const result = component.isSignedIn(); + expect(result).toBeDefined(); + expect(typeof result).toEqual('boolean'); + }); }); diff --git a/src/app/sign-in/sign-in.component.ts b/src/app/sign-in/sign-in.component.ts index e4ee66d..699a12b 100644 --- a/src/app/sign-in/sign-in.component.ts +++ b/src/app/sign-in/sign-in.component.ts @@ -1,3 +1,4 @@ +import {PlatformLocation} from '@angular/common'; import {Component, Inject, OnInit} from '@angular/core'; import {FormGroup} from '@angular/forms'; import {Router} from '@angular/router'; @@ -56,7 +57,8 @@ export class SignInComponent implements OnInit { constructor( @Inject('APP_ENVIRONMENT') private environment: AppEnvironment, private router: Router, - private api: ApiService + private api: ApiService, + private platformLocation: PlatformLocation ) { } @@ -70,6 +72,7 @@ export class SignInComponent implements OnInit { // For testing purposes, create a user to simulate login. if (!this.environment.production) { + this.model.redirect_url = this.platformLocation.href + 'session'; this.api.openSession(this.model); } else { this.error = new Error('This feature does not work in production.'); From fbbe830218c76e7e3e1af1d5f1275043201cf96e Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 24 Feb 2020 17:15:25 -0500 Subject: [PATCH 4/4] Adds some meaningful e2e tests. --- e2e/src/app.e2e-spec.ts | 19 ++++- e2e/src/app.po.ts | 89 +++++++++++++++++++++- src/app/file-list/file-list.component.html | 6 +- 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/e2e/src/app.e2e-spec.ts b/e2e/src/app.e2e-spec.ts index c55d88c..c28cd55 100644 --- a/e2e/src/app.e2e-spec.ts +++ b/e2e/src/app.e2e-spec.ts @@ -7,9 +7,24 @@ describe('workspace-project App', () => { page = new AppPage(); }); - it('should display diagram', () => { + it('should display fake sign-in screen', () => { page.navigateTo(); - expect(page.getDiagramContainer()).toBeTruthy(); + expect(page.getText('h1')).toEqual('FAKE UVA NETBADGE SIGN IN (FOR TESTING ONLY)'); + }); + + it('should click sign-in and navigate to home screen', () => { + page.clickAndExpectRoute('#sign_in', '/'); + expect(page.getElements('app-file-list').count()).toBeGreaterThan(0); + }); + + it('should display diagram', async () => { + const el = await page.getElement('app-file-list mat-list-item'); + const specId = await el.getAttribute('data-workflow-spec-id'); + const fileMetaId = await el.getAttribute('data-file-meta-id'); + const expectedRoute = `/modeler/${specId}/${fileMetaId}`; + + page.clickAndExpectRoute('app-file-list mat-list-item', expectedRoute); + expect(page.getElements('.diagram-container').count()).toBeGreaterThan(0); }); it('should show dialog to open a diagram file'); diff --git a/e2e/src/app.po.ts b/e2e/src/app.po.ts index 631b86b..d9b221e 100644 --- a/e2e/src/app.po.ts +++ b/e2e/src/app.po.ts @@ -1,4 +1,4 @@ -import { browser, by, element } from 'protractor'; +import {browser, by, element, ElementArrayFinder, ElementFinder, ExpectedConditions} from 'protractor'; export class AppPage { navigateTo() { @@ -8,4 +8,91 @@ export class AppPage { getDiagramContainer() { return element(by.css('app-root .diagram-container')); } + + clickAndExpectRoute(clickSelector: string, expectedRoute: string | RegExp) { + this.waitForClickable(clickSelector); + this.clickElement(clickSelector); + if (typeof expectedRoute === 'string') { + expect(this.getRoute()).toEqual(expectedRoute); + } else { + expect(this.getRoute()).toMatch(expectedRoute); + } + } + + clickElement(selector: string) { + this.waitForClickable(selector); + this.scrollTo(selector); + this.focus(selector); + return this.getElement(selector).click(); + } + + closeTab() { + return browser.close(); + } + + focus(selector: string) { + return browser.controlFlow().execute(() => { + return browser.executeScript('arguments[0].focus()', this.getElement(selector).getWebElement()); + }); + } + + getElement(selector: string): ElementFinder { + return element.all(by.css(selector)).first(); + } + + getElements(selector: string): ElementArrayFinder { + return element.all(by.css(selector)); + } + + getLocalStorageVar(name: string) { + return browser.executeScript(`return window.localStorage.getItem('${name}');`); + } + + getNumTabs() { + return browser.getAllWindowHandles().then(wh => { + return wh.length; + }); + } + + async getRoute() { + const url = await this.getUrl(); + return '/' + url.split(browser.baseUrl)[1]; + } + + getText(selector: string) { + return element(by.css(selector)).getText() as Promise; + } + + getUrl() { + return browser.getCurrentUrl(); + } + + scrollTo(selector: string) { + browser.controlFlow().execute(() => { + browser.executeScript('arguments[0].scrollIntoView(false)', this.getElement(selector).getWebElement()); + }); + } + + setLocalStorageVar(name: string, value: string) { + return browser.executeScript(`return window.localStorage.setItem('${name}','${value}');`); + } + + switchFocusToTab(tabIndex: number) { + return browser.getAllWindowHandles().then(wh => { + return wh.forEach((h, i) => { + if (i === tabIndex) { + return browser.switchTo().window(h); + } + }); + }); + } + + waitFor(t: number) { + return browser.sleep(t); + } + + waitForClickable(selector: string) { + const e = this.getElement(selector); + return browser.wait(ExpectedConditions.elementToBeClickable(e), 5000); + } } diff --git a/src/app/file-list/file-list.component.html b/src/app/file-list/file-list.component.html index ad52451..fdf1c96 100644 --- a/src/app/file-list/file-list.component.html +++ b/src/app/file-list/file-list.component.html @@ -1,5 +1,9 @@ - + {{fm.type | getIconCode}}