diff --git a/src/app/auth/pending-verification/pending-verification.page.html b/src/app/auth/pending-verification/pending-verification.page.html index 0e31f3bb36..a0e095eca1 100644 --- a/src/app/auth/pending-verification/pending-verification.page.html +++ b/src/app/auth/pending-verification/pending-verification.page.html @@ -1,26 +1,83 @@ - - - + + + + + + + + The invitation has expired + + Enter your registered email address to receive a new invitation and set up your account on Fyle. + + + + Registered Email + + + Please enter a valid email. + + + + + + Send invite + + + + Back to Sign In + + + + + + + + + + Invitation link sent + + A new invitation link has been sent to your registered email address. Check your inbox to continue setting up + your account. + + + + + + + + Back to Sign In + + + + + diff --git a/src/app/auth/pending-verification/pending-verification.page.scss b/src/app/auth/pending-verification/pending-verification.page.scss new file mode 100644 index 0000000000..8463203eba --- /dev/null +++ b/src/app/auth/pending-verification/pending-verification.page.scss @@ -0,0 +1,198 @@ +@import '../../../theme/colors.scss'; + +.pending-verification { + height: 100%; + + &__send-invite { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 0 20px 24px 20px; + } + + &__form-container { + display: flex; + flex-direction: column; + padding-top: 24px; + justify-content: flex-start; + height: 100%; + } + + &__error-icon { + width: 45px; + height: 45px; + fill: $red; + } + + &__error-icon-container { + width: 40px; + height: 40px; + border-radius: 8px; + background: $pale-pink; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + padding: 10px; + } + + &__page-header { + font-size: 20px; + margin: 0 0 10px; + position: relative; + margin-bottom: 24px; + } + + &__disabled { + opacity: 0.2; + } + + &__sub-header { + font-size: 14px; + margin: 0 0 10px; + position: relative; + margin-bottom: 24px; + color: $black-light; + } + + &__error-message { + color: $red; + } + + &__input-container { + &__label { + margin: 0 8px 0 0; + font-size: 12px; + color: $black-light; + line-height: 1.3; + font-weight: 400; + } + + &__input { + border: 0; + font-size: 14px; + font-weight: 400; + line-height: 1.3; + color: $blue-black; + width: 100%; + padding: 6px 0; + border-bottom: 1px solid $grey-lighter; + } + + &__input:focus { + border-bottom: 1px solid $blue-black; + } + } + + &__content-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-grow: 2; + } + + &__arrow-icon { + fill: $pure-white; + margin-right: 8px; + } + + &__cta-text { + font-size: 14px; + font-weight: 500; + } + + &__cta-content { + display: flex; + align-items: center; + justify-content: center; + } + + &__success-message { + height: 100%; + display: flex; + flex-direction: column; + } + + &__success-icon-container { + width: 60px; + height: 60px; + border-radius: 8px; + background: $success-bg; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + } + + &__success-icon { + width: 45px; + height: 45px; + fill: $green; + } + + &__text { + display: flex; + align-items: center; + gap: 8px; + flex-direction: column; + } + + &__header { + color: $black; + font-size: 20px; + font-style: normal; + font-weight: 500; + line-height: normal; + height: 26px; + margin: 0; + } + + &__resend-text { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + color: $black-light; + font-weight: 500; + gap: 2px; + + &__resend-link { + color: $brand-primary; + } + + &__spinner-icon { + color: $brand-primary; + height: 12px; + } + } + + &__content { + color: $black-light; + text-align: center; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 1.25; + height: 36px; + width: 274px; + + &__reset-email { + font-weight: 500; + } + } + &__cta-secondary { + display: flex; + padding-top: 12px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 24px; + align-self: stretch; + color: $blue-black; + display: flex; + flex-direction: row; + gap: 6px; + } +} diff --git a/src/app/auth/pending-verification/pending-verification.page.spec.ts b/src/app/auth/pending-verification/pending-verification.page.spec.ts index 17271dc667..c6451a12f8 100644 --- a/src/app/auth/pending-verification/pending-verification.page.spec.ts +++ b/src/app/auth/pending-verification/pending-verification.page.spec.ts @@ -1,48 +1,76 @@ import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { IonicModule } from '@ionic/angular'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { PendingVerificationPage } from './pending-verification.page'; -import { PageState } from 'src/app/core/models/page-state.enum'; import { RouterAuthService } from 'src/app/core/services/router-auth.service'; import { of, throwError } from 'rxjs'; import { authResData1 } from 'src/app/core/mock-data/auth-reponse.data'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; +import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SnackbarPropertiesService } from 'src/app/core/services/snackbar-properties.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ToastMessageComponent } from 'src/app/shared/components/toast-message/toast-message.component'; +import { HttpErrorResponse } from '@angular/common/http'; +import { getElementRef } from 'src/app/core/dom-helpers'; describe('PendingVerificationPage', () => { let component: PendingVerificationPage; let fixture: ComponentFixture; let router: jasmine.SpyObj; - let activatedRoute: jasmine.SpyObj; let routerAuthService: jasmine.SpyObj; + let matSnackBar: jasmine.SpyObj; + let snackbarPropertiesService: jasmine.SpyObj; + let activatedRoute: jasmine.SpyObj; + let formBuilder: jasmine.SpyObj; + let fb: FormBuilder; beforeEach(waitForAsync(() => { const routerSpy = jasmine.createSpyObj('Router', ['navigate']); const routerAuthServiceSpy = jasmine.createSpyObj('RouterAuthService', ['resendVerificationLink']); - + const matSnackBarSpy = jasmine.createSpyObj('MatSnackBar', ['openFromComponent']); + const snackbarPropertiesServiceSpy = jasmine.createSpyObj('SnackbarPropertiesService', ['setSnackbarProperties']); TestBed.configureTestingModule({ declarations: [PendingVerificationPage], - imports: [IonicModule.forRoot()], + imports: [IonicModule.forRoot(), RouterTestingModule, RouterModule, FormsModule, ReactiveFormsModule], providers: [ - { provide: Router, useValue: routerSpy }, - { - provide: ActivatedRoute, - useValue: { snapshot: { params: { orgId: 'orNVthTo2Zyo' } } }, - }, + FormBuilder, { provide: RouterAuthService, useValue: routerAuthServiceSpy, }, + { + provide: Router, + useValue: routerSpy, + }, + { + provide: MatSnackBar, + useValue: matSnackBarSpy, + }, + { + provide: SnackbarPropertiesService, + useValue: snackbarPropertiesServiceSpy, + }, + { + provide: ActivatedRoute, + useValue: { snapshot: { params: { email: 'aastha.b@fyle.in' } } }, + }, ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(PendingVerificationPage); component = fixture.componentInstance; router = TestBed.inject(Router) as jasmine.SpyObj; - activatedRoute = TestBed.inject(ActivatedRoute) as jasmine.SpyObj; routerAuthService = TestBed.inject(RouterAuthService) as jasmine.SpyObj; + matSnackBar = TestBed.inject(MatSnackBar) as jasmine.SpyObj; + snackbarPropertiesService = TestBed.inject(SnackbarPropertiesService) as jasmine.SpyObj; + activatedRoute = TestBed.inject(ActivatedRoute) as jasmine.SpyObj; + fb = TestBed.inject(FormBuilder); + activatedRoute.snapshot.params.orgId = 'orNVthTo2Zyo'; + component.fg = fb.group({ + email: [Validators.compose([Validators.required, Validators.pattern('\\S+@\\S+\\.\\S{2,}')])], + }); fixture.detectChanges(); })); @@ -50,33 +78,6 @@ describe('PendingVerificationPage', () => { expect(component).toBeTruthy(); }); - it('should set hasTokenExpired to true if snapshot.params.hasTokenExpired is defined and true on ionViewWillEnter()', () => { - activatedRoute.snapshot.params.hasTokenExpired = true; - component.ionViewWillEnter(); - fixture.detectChanges(); - const pageTitle = fixture.debugElement.query(By.css('app-send-email')).nativeElement.title; - expect(component.hasTokenExpired).toBe(true); - expect(pageTitle).toEqual('Verification link expired'); - }); - - it('should set hasTokenExpired to false if snapshot.params.hasTokenExpired is defined and false on ionViewWillEnter()', () => { - activatedRoute.snapshot.params.hasTokenExpired = false; - component.ionViewWillEnter(); - fixture.detectChanges(); - const pageTitle = fixture.debugElement.query(By.css('app-send-email')).nativeElement.title; - expect(component.hasTokenExpired).toBe(false); - expect(pageTitle).toEqual('Please verify your email'); - }); - - it('should set hasTokenExpired to false and currentPageState to notSent if snapshot.params.hasTokenExpired is not defined on ionViewWillEnter()', () => { - component.ionViewWillEnter(); - fixture.detectChanges(); - const pageTitle = fixture.debugElement.query(By.css('app-send-email')).nativeElement.title; - expect(component.hasTokenExpired).toBe(false); - expect(component.currentPageState).toEqual(PageState.notSent); - expect(pageTitle).toEqual('Please verify your email'); - }); - it('resendVerificationLink(): should call routerAuthService and set PageState to success if API is successful', fakeAsync(() => { const data = { cluster_domain: authResData1.cluster_domain, @@ -86,36 +87,80 @@ describe('PendingVerificationPage', () => { tick(1000); expect(routerAuthService.resendVerificationLink).toHaveBeenCalledOnceWith('ajain@fyle.in', 'orNVthTo2Zyo'); expect(component.isLoading).toBeFalse(); - expect(component.currentPageState).toEqual(PageState.success); })); it('resendVerificationLink(): should call routerAuthService and call handleError if API is unsuccessful', fakeAsync(() => { - const error = new Error('An Error Occured'); + const error = { status: 500 } as HttpErrorResponse; routerAuthService.resendVerificationLink.and.returnValue(throwError(() => error)); spyOn(component, 'handleError'); component.resendVerificationLink('ajain@fyle.in'); tick(1000); expect(routerAuthService.resendVerificationLink).toHaveBeenCalledOnceWith('ajain@fyle.in', 'orNVthTo2Zyo'); expect(component.isLoading).toBeTrue(); - expect(component.currentPageState).not.toEqual(PageState.success); expect(component.handleError).toHaveBeenCalledOnceWith(error); })); - it('handleError(); should navigate to auth/disabled if status code is 422', () => { - const error = { - status: 422, - }; - component.handleError(error); - expect(router.navigate).toHaveBeenCalledOnceWith(['/', 'auth', 'disabled']); - expect(component.currentPageState).not.toEqual(PageState.failure); + describe('handleError():', () => { + it('handleError(); should navigate to auth/disabled if status code is 422', () => { + const error = { + status: 422, + } as HttpErrorResponse; + component.handleError(error); + expect(router.navigate).toHaveBeenCalledOnceWith(['/', 'auth', 'disabled']); + }); + + it('should display error message on other errors', () => { + const error = { status: 401 } as HttpErrorResponse; + const props = { + panelClass: ['msb-failure'], + }; + + matSnackBar.openFromComponent.and.callThrough(); + + component.handleError(error); + expect(matSnackBar.openFromComponent).toHaveBeenCalledOnceWith(ToastMessageComponent, { + ...props, + panelClass: ['msb-failure'], + }); + expect(snackbarPropertiesService.setSnackbarProperties).toHaveBeenCalledTimes(1); + }); }); - it('handleError(); should set pagestate to failure if status code', () => { - const error = { - status: 422, - }; - component.handleError(error); - expect(router.navigate).toHaveBeenCalledOnceWith(['/', 'auth', 'disabled']); - expect(component.currentPageState).not.toEqual(PageState.failure); + it('onGotoSignInClick(): should navigate to sign-in page', () => { + component.onGotoSignInClick(); + expect(router.navigate).toHaveBeenCalledWith(['/', 'auth', 'sign_in']); + }); + + describe('template', () => { + it('should render the form for entering email', () => { + component.isInvitationLinkSent = false; + fixture.detectChanges(); + + const formElement = fixture.debugElement.query(By.css('.pending-verification__form-container')); + expect(formElement).toBeTruthy(); + }); + + it('should display validation error for invalid email input', () => { + component.isInvitationLinkSent = false; + const emailControl = component.fg.controls.email; + emailControl.setValue('invalid-email'); + emailControl.markAsTouched(); + fixture.detectChanges(); + + const errorElement = getElementRef(fixture, '.pending-verification__error-message'); + expect(errorElement.nativeElement.textContent).toContain('Please enter a valid email.'); + }); + + it('should call resendVerificationLink with correct email when button is clicked', () => { + component.isInvitationLinkSent = false; + spyOn(component, 'resendVerificationLink'); + component.fg.controls.email.setValue('test@example.com'); + fixture.detectChanges(); + + const buttonElement = fixture.debugElement.query(By.css('ion-button')); + buttonElement.triggerEventHandler('click', null); + + expect(component.resendVerificationLink).toHaveBeenCalledWith('test@example.com'); + }); }); }); diff --git a/src/app/auth/pending-verification/pending-verification.page.ts b/src/app/auth/pending-verification/pending-verification.page.ts index 814941636a..d691122a53 100644 --- a/src/app/auth/pending-verification/pending-verification.page.ts +++ b/src/app/auth/pending-verification/pending-verification.page.ts @@ -2,50 +2,70 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { tap } from 'rxjs/operators'; import { RouterAuthService } from 'src/app/core/services/router-auth.service'; -import { PageState } from 'src/app/core/models/page-state.enum'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SnackbarPropertiesService } from 'src/app/core/services/snackbar-properties.service'; +import { ToastMessageComponent } from 'src/app/shared/components/toast-message/toast-message.component'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-pending-verification', templateUrl: './pending-verification.page.html', + styleUrls: ['./pending-verification.page.scss'], }) export class PendingVerificationPage implements OnInit { - currentPageState: PageState; - isLoading = false; - hasTokenExpired = false; + isInvitationLinkSent = false; + + fg: FormGroup; constructor( + private formBuilder: FormBuilder, private routerAuthService: RouterAuthService, private router: Router, - private activatedRoute: ActivatedRoute + private activatedRoute: ActivatedRoute, + private matSnackBar: MatSnackBar, + private snackbarProperties: SnackbarPropertiesService ) {} - ngOnInit() {} - - ionViewWillEnter() { - this.hasTokenExpired = this.activatedRoute.snapshot.params.hasTokenExpired || false; - this.currentPageState = PageState.notSent; + ngOnInit(): void { + this.fg = this.formBuilder.group({ + email: ['', Validators.compose([Validators.required, Validators.pattern('\\S+@\\S+\\.\\S{2,}')])], + }); } - resendVerificationLink(email: string) { + resendVerificationLink(email: string): void { this.isLoading = true; - const orgId = this.activatedRoute.snapshot.params.orgId; + const orgId = this.activatedRoute.snapshot.params.orgId as string; this.routerAuthService .resendVerificationLink(email, orgId) .pipe(tap(() => (this.isLoading = false))) .subscribe({ - next: () => (this.currentPageState = PageState.success), - error: (err) => this.handleError(err), + next: () => { + this.isInvitationLinkSent = true; + }, + error: (err: HttpErrorResponse) => this.handleError(err), }); } - handleError(err: any) { + handleError(err: HttpErrorResponse): void { if (err.status === 422) { this.router.navigate(['/', 'auth', 'disabled']); } else { - this.currentPageState = PageState.failure; + const toastMessageData = { + message: 'Something went wrong. Please try after some time.', + }; + + this.matSnackBar.openFromComponent(ToastMessageComponent, { + ...this.snackbarProperties.setSnackbarProperties('failure', toastMessageData), + panelClass: ['msb-failure'], + }); } } + + onGotoSignInClick(): void { + this.router.navigate(['/', 'auth', 'sign_in']); + } }
+ A new invitation link has been sent to your registered email address. Check your inbox to continue setting up + your account. +