diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1a24264..e43d1f0 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,7 @@ import { Routes } from '@angular/router'; import { Main } from './pages/main/main'; +import { Edit } from './pages/edit/edit'; +import { contactResolver } from './pages/edit/contact-resolver'; export const routes: Routes = [ { @@ -7,8 +9,13 @@ export const routes: Routes = [ component: Main, pathMatch: 'full', }, + { + path: 'edit/:id', + component: Edit, + resolve: { contact: contactResolver }, + }, { path: '**', redirectTo: '', - } + }, ]; diff --git a/src/app/components/contact-list-table/contact-list-table.spec.ts b/src/app/components/contact-list-table/contact-list-table.spec.ts index 6753bfe..58cf2a8 100644 --- a/src/app/components/contact-list-table/contact-list-table.spec.ts +++ b/src/app/components/contact-list-table/contact-list-table.spec.ts @@ -7,21 +7,25 @@ import { ContactService } from '../../pages/main/contact.service'; import { ContactDTO } from '../../models/ContactDTO'; import { By } from '@angular/platform-browser'; import { of } from 'rxjs'; +import { Router } from '@angular/router'; describe('ContactListTable', () => { let component: ContactListTable; let fixture: ComponentFixture; let contactService: jasmine.SpyObj; + let router: jasmine.SpyObj; let CONTACT_LIST_MOCK: ContactDTO[]; beforeEach(async () => { contactService = jasmine.createSpyObj(ContactService.name, ['delete']); + router = jasmine.createSpyObj(Router.name, ['navigate']); CONTACT_LIST_MOCK = [new ContactDTO(1, 'MOCK', 'MOCK', '5491122222222')]; await TestBed.configureTestingModule({ imports: [ContactListTable], providers: [ { provide: STRINGS_INJECTOR, useValue: strings }, { provide: ContactService, useValue: contactService }, + { provide: Router, useValue: router }, ], }).compileComponents(); contactService.delete.and.returnValue(of([])); @@ -36,7 +40,7 @@ describe('ContactListTable', () => { }); describe('delete', () => { - it('should call for valid ID', () => { + it('should call delete for valid ID', () => { const deleteSpy = spyOn(component, 'delete').and.callThrough(); const deleteButton = fixture.debugElement .query(By.css('app-contact-actions-bar')) @@ -55,4 +59,22 @@ describe('ContactListTable', () => { }); }); + describe('edit', () => { + it("shouldn't navigate if ID is falsy", () => { + CONTACT_LIST_MOCK[0].id = undefined; + const editButton = fixture.debugElement + .query(By.css('app-contact-actions-bar')) + .query(By.css('.btn--edit')); + editButton.triggerEventHandler('click'); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should call navigate for valid ID', () => { + const editButton = fixture.debugElement + .query(By.css('app-contact-actions-bar')) + .query(By.css('.btn--edit')); + editButton.triggerEventHandler('click'); + expect(router.navigate).toHaveBeenCalledOnceWith(['edit', CONTACT_LIST_MOCK[0].id]); + }); + }); }); diff --git a/src/app/components/contact-list-table/contact-list-table.ts b/src/app/components/contact-list-table/contact-list-table.ts index 2ad4ffd..3cfbad1 100644 --- a/src/app/components/contact-list-table/contact-list-table.ts +++ b/src/app/components/contact-list-table/contact-list-table.ts @@ -1,8 +1,9 @@ import { Component, inject, input } from '@angular/core'; import { STRINGS_INJECTOR } from '../../app.config'; import { ContactDTO } from '../../models/ContactDTO'; -import { ContactActionsBar } from "../contact-actions-bar/contact-actions-bar"; +import { ContactActionsBar } from '../contact-actions-bar/contact-actions-bar'; import { ContactService } from '../../pages/main/contact.service'; +import { Router } from '@angular/router'; @Component({ selector: 'app-contact-list-table', @@ -14,14 +15,15 @@ export class ContactListTable { contactList = input([]); protected readonly strings = inject(STRINGS_INJECTOR); private readonly contactService = inject(ContactService); + private readonly router = inject(Router); edit(id?: number) { - if(!id) return; + if (!id) return; + this.router.navigate(['edit', id]); } delete(id?: number) { - if(!id) return; + if (!id) return; this.contactService.delete(id).subscribe(); } - } diff --git a/src/app/pages/edit/contact-resolver.spec.ts b/src/app/pages/edit/contact-resolver.spec.ts new file mode 100644 index 0000000..8dc84a8 --- /dev/null +++ b/src/app/pages/edit/contact-resolver.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, ResolveFn, Router } from '@angular/router'; +import { contactResolver } from './contact-resolver'; +import { Observable } from 'rxjs'; +import { ContactDTO } from '../../models/ContactDTO'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { environment } from '../../../environments/environment'; + +describe('contactResolver', () => { + let httpTestingController: HttpTestingController; + let router: jasmine.SpyObj; + + let ROUTE_MOCK: jasmine.SpyObj; + + const executeResolver: ResolveFn> = (...resolverParameters) => + TestBed.runInInjectionContext(() => contactResolver(...resolverParameters)); + + beforeEach(() => { + router = jasmine.createSpyObj(Router.name, ['navigate']); + ROUTE_MOCK = jasmine.createSpyObj(ActivatedRouteSnapshot.name, [], { + paramMap: { + get() { + return '1'; + }, + }, + }); + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: Router, useValue: router }, + ], + }); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + it('should be created', () => { + expect(executeResolver).toBeTruthy(); + }); + + it('should navigate back on error response', () => { + ( + executeResolver(ROUTE_MOCK, { + url: '', + root: new ActivatedRouteSnapshot(), + }) as Observable + ).subscribe(); + const req = httpTestingController.expectOne( + `${environment.apiUrl}/contacts/${ROUTE_MOCK.paramMap.get('id')}` + ); + expect(req.request.method).toEqual('GET'); + req.flush( + { + success: false, + errors: [ + { + code: 4400, + message: 'Resource not found', + details: 'Contact not found', + }, + ], + }, + { status: 404, statusText: 'Resource not found' } + ); + expect(router.navigate).toHaveBeenCalledOnceWith(['/']); + }); + + it('should return and observable with the fetched contact', () => { + ( + executeResolver(ROUTE_MOCK, { + url: '', + root: new ActivatedRouteSnapshot(), + }) as Observable + ).subscribe(); + const req = httpTestingController.expectOne( + `${environment.apiUrl}/contacts/${ROUTE_MOCK.paramMap.get('id')}` + ); + expect(req.request.method).toEqual('GET'); + req.flush({ + data: new ContactDTO(1, 'mock', 'mock', 'mock'), + success: true, + message: 'Contact retrieved successfully', + }); + httpTestingController.verify(); + }); +}); diff --git a/src/app/pages/edit/contact-resolver.ts b/src/app/pages/edit/contact-resolver.ts new file mode 100644 index 0000000..f726efc --- /dev/null +++ b/src/app/pages/edit/contact-resolver.ts @@ -0,0 +1,19 @@ +import { inject } from '@angular/core'; +import { ResolveFn, Router } from '@angular/router'; +import { ContactService } from '../main/contact.service'; +import { ContactDTO } from '../../models/ContactDTO'; +import { catchError, map, Observable, of } from 'rxjs'; + +export const contactResolver: ResolveFn> = (route, _) => { + const contactService = inject(ContactService); + const router = inject(Router); + const contactId = route.paramMap.get('id') ?? '0'; + + return contactService.findById(contactId).pipe( + map((response) => response.data), + catchError(() => { + router.navigate(['/']); + return of(null); + }) + ); +}; diff --git a/src/app/pages/edit/edit.html b/src/app/pages/edit/edit.html new file mode 100644 index 0000000..e65a556 --- /dev/null +++ b/src/app/pages/edit/edit.html @@ -0,0 +1,24 @@ + + + + + @if(form$|async; as form){ + + + } + \ No newline at end of file diff --git a/src/app/pages/edit/edit.scss b/src/app/pages/edit/edit.scss new file mode 100644 index 0000000..151fb6b --- /dev/null +++ b/src/app/pages/edit/edit.scss @@ -0,0 +1,8 @@ +app-card { + margin-top: 2rem; + width: 100%; +} +.form { + display: block; + padding: 3rem; +} diff --git a/src/app/pages/edit/edit.spec.ts b/src/app/pages/edit/edit.spec.ts new file mode 100644 index 0000000..054af6b --- /dev/null +++ b/src/app/pages/edit/edit.spec.ts @@ -0,0 +1,70 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Edit } from './edit'; +import { STRINGS_INJECTOR } from '../../app.config'; +import { strings } from '../../strings'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ContactService } from '../main/contact.service'; +import { of } from 'rxjs'; +import { ContactDTO } from '../../models/ContactDTO'; +import { By } from '@angular/platform-browser'; + +describe('Edit', () => { + let component: Edit; + let fixture: ComponentFixture; + + let activatedRoute: jasmine.SpyObj; + let contactService: jasmine.SpyObj; + let router: jasmine.SpyObj; + + let CONTACT_MOCK: ContactDTO; + + beforeEach(async () => { + CONTACT_MOCK = new ContactDTO(1, 'mock', 'mock', 'mock'); + activatedRoute = jasmine.createSpyObj(ActivatedRoute.name, [], { + data: of({ contact: CONTACT_MOCK }), + }); + contactService = jasmine.createSpyObj(ContactService.name, ['update']); + router = jasmine.createSpyObj(Router.name, ['navigate']); + + await TestBed.configureTestingModule({ + imports: [Edit], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: ContactService, useValue: contactService }, + { provide: Router, useValue: router }, + { provide: STRINGS_INJECTOR, useValue: strings }, + ], + }).compileComponents(); + + TestBed.inject(ActivatedRoute); + fixture = TestBed.createComponent(Edit); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should update on form submission', () => { + contactService.update.and.returnValue( + of({ success: true, data: null, message: 'Contact updated successfully' }) + ); + const MODIFICATION_MOCK = 'modified'; + const nameInput = fixture.debugElement + .query(By.css('[formControlName="name"]')) + .query(By.css('input')); + nameInput.triggerEventHandler('input', { target: { value: MODIFICATION_MOCK } }); + CONTACT_MOCK.name = MODIFICATION_MOCK; + const submitBtn = fixture.debugElement.query(By.css('app-squared-btn')).query(By.css('button')); + (submitBtn.nativeElement).click(); + expect(contactService.update).toHaveBeenCalledOnceWith(CONTACT_MOCK); + }); + + it('should navigate back', () => { + const goBackBtn = fixture.debugElement.query(By.css('app-rounded-btn')); + goBackBtn.triggerEventHandler('click'); + expect(router.navigate).toHaveBeenCalledOnceWith(['./..'], { relativeTo: activatedRoute }); + }); +}); diff --git a/src/app/pages/edit/edit.ts b/src/app/pages/edit/edit.ts new file mode 100644 index 0000000..c5db554 --- /dev/null +++ b/src/app/pages/edit/edit.ts @@ -0,0 +1,56 @@ +import { Component, inject } from '@angular/core'; +import { MainHeader } from '../../components/main-header/main-header'; +import { STRINGS_INJECTOR } from '../../app.config'; +import { RoundedBtn } from '../../components/rounded-btn/rounded-btn'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Card } from '../../components/card/card'; +import { ContactForm } from '../../components/contact-form/contact-form'; +import { FormHeader } from '../../components/form-header/form-header'; +import { UpperfirstPipe } from '../../pipes/upperfirst-pipe'; +import { map, Observable, take, tap } from 'rxjs'; +import { ContactDTO } from '../../models/ContactDTO'; +import { FormGroupContact } from '../../utils/form-group-contact'; +import { AsyncPipe } from '@angular/common'; +import { ContactService } from '../main/contact.service'; +import { ContactFormValue } from '../../types/ContactFormValue.type'; + +@Component({ + selector: 'app-edit', + imports: [MainHeader, RoundedBtn, Card, ContactForm, FormHeader, UpperfirstPipe, AsyncPipe], + templateUrl: './edit.html', + styleUrl: './edit.scss', +}) +export class Edit { + protected readonly strings = inject(STRINGS_INJECTOR); + private readonly activatedRoute = inject(ActivatedRoute); + private readonly contactService = inject(ContactService); + private readonly router = inject(Router); + + protected contactId?: number; + protected readonly form$ = (>this.activatedRoute.data).pipe( + take(1), + map((data) => data.contact), + tap((contact) => (this.contactId = contact.id)), + map((contact) => new FormGroupContact(contact.name, contact.company, contact.phone)) + ); + + edit(form: ContactFormValue) { + if (this.contactId) { + const contact = new ContactDTO( + this.contactId, + form.name ?? '', + form.company ?? '', + form.phone ?? '' + ); + + this.contactService + .update(contact) + .pipe(tap(() => this.goBack())) + .subscribe(); + } + } + + goBack() { + this.router.navigate(['./..'], { relativeTo: this.activatedRoute }); + } +}