feat: add edit page

This commit is contained in:
2025-12-18 22:59:53 -03:00
parent 70b7627076
commit be86d2353d
9 changed files with 302 additions and 6 deletions

View File

@@ -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: '',
}
},
];

View File

@@ -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<ContactListTable>;
let contactService: jasmine.SpyObj<ContactService>;
let router: jasmine.SpyObj<Router>;
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]);
});
});
});

View File

@@ -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<ContactDTO[]>([]);
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();
}
}

View File

@@ -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<Router>;
let ROUTE_MOCK: jasmine.SpyObj<ActivatedRouteSnapshot>;
const executeResolver: ResolveFn<Observable<ContactDTO | null>> = (...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<ContactDTO | null>
).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<ContactDTO | null>
).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();
});
});

View File

@@ -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<Observable<ContactDTO | null>> = (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);
})
);
};

View File

@@ -0,0 +1,24 @@
<app-main-header
[title]="strings.editContact"
>
<app-rounded-btn
(keydown)="goBack()"
(click)="goBack()"
slot="action-button"
text="Go back"
/>
</app-main-header>
<app-card bgColor="var(--secondary)">
@if(form$|async; as form){
<app-contact-form
class="form"
(contact)="edit(form.value)"
[form]="form"
[submitText]="strings.save"
><app-form-header
[title]="strings.editTheContact|upperfirst"
slot="header"
/>
</app-contact-form>
}
</app-card>

View File

@@ -0,0 +1,8 @@
app-card {
margin-top: 2rem;
width: 100%;
}
.form {
display: block;
padding: 3rem;
}

View File

@@ -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<Edit>;
let activatedRoute: jasmine.SpyObj<ActivatedRoute>;
let contactService: jasmine.SpyObj<ContactService>;
let router: jasmine.SpyObj<Router>;
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'));
(<HTMLButtonElement>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 });
});
});

View File

@@ -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$ = (<Observable<{ contact: ContactDTO }>>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 });
}
}