diff --git a/src/app/adapters/projects/project.facade.ts b/src/app/adapters/projects/project.facade.ts index 4d11848..c54cf89 100644 --- a/src/app/adapters/projects/project.facade.ts +++ b/src/app/adapters/projects/project.facade.ts @@ -1,9 +1,9 @@ import { inject, Injectable, signal } from '@angular/core'; import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token'; -import { CreateProjectUseCase } from '../../application/projects/create-project.usecase'; -import { ListProjectUseCase } from '../../application/projects/list-project.usecase'; -import { GetProjectUseCase } from '../../application/projects/get-project.usecase'; -import { UpdateProjectUseCase } from '../../application/projects/update-project.usecase'; +import { CreateProjectUseCase } from '@app/application/projects/create-project.usecase'; +import { ListProjectUseCase } from '@app/application/projects/list-project.usecase'; +import { GetProjectUseCase } from '@app/application/projects/get-project.usecase'; +import { UpdateProjectUseCase } from '@app/application/projects/update-project.usecase'; import { Project } from '@app/domain/projects/project.model'; import { ProjectViewModel } from '../projects/project.presenter.model'; import { ProjectPresenter } from '../projects/project.presenter'; @@ -13,6 +13,7 @@ import { ActionType } from '@app/domain/action-type.util'; import { LoaderAction } from '@app/domain/loader-action.util'; import { first, Subscription } from 'rxjs'; import { FeedbackService } from '../shared/services/feedback.service'; +import { DeleteProjectUseCase } from '@app/application/projects/delete-project.usecase'; @Injectable({ providedIn: 'root', @@ -24,7 +25,8 @@ export class ProjectFacade { private readonly createUseCase = new CreateProjectUseCase(this.projectRepo); private readonly listUseCase = new ListProjectUseCase(this.projectRepo); private readonly getUseCase = new GetProjectUseCase(this.projectRepo); - private readonly UpdateUseCase = new UpdateProjectUseCase(this.projectRepo); + private readonly updateUseCase = new UpdateProjectUseCase(this.projectRepo); + private readonly deleteUseCase = new DeleteProjectUseCase(this.projectRepo); readonly projects = signal([]); readonly project = signal({} as ProjectViewModel); @@ -101,7 +103,8 @@ export class ProjectFacade { update(userId: string, data: any) { this.handleError(ActionType.UPDATE, false, null, true); - this.UpdateUseCase.execute(userId, data) + this.updateUseCase + .execute(userId, data) .pipe(first()) .subscribe({ next: (project: Project) => { @@ -117,6 +120,26 @@ export class ProjectFacade { }); } + delete(projectId: string) { + this.handleError(ActionType.DELETE, false, null, true); + + this.deleteUseCase + .execute(projectId) + .pipe(first()) + .subscribe({ + next: (res: boolean) => { + if (res) { + this.handleError(ActionType.UPDATE, false, null, false); + const message = `Le projet a bien été supprimé !`; + this.feedbackService.notify(ActionType.UPDATE, message); + } + }, + error: (err) => { + this.handleError(ActionType.UPDATE, false, err, false); + }, + }); + } + private handleError( action: ActionType = ActionType.NONE, hasError: boolean, diff --git a/src/app/application/projects/delete-project.usecase.ts b/src/app/application/projects/delete-project.usecase.ts new file mode 100644 index 0000000..d3f9ee0 --- /dev/null +++ b/src/app/application/projects/delete-project.usecase.ts @@ -0,0 +1,10 @@ +import { ProjectRepository } from '@app/domain/projects/project.repository'; +import { Observable } from 'rxjs'; + +export class DeleteProjectUseCase { + constructor(private readonly repo: ProjectRepository) {} + + execute(projectId: string): Observable { + return this.repo.delete(projectId); + } +} diff --git a/src/app/domain/projects/project.repository.ts b/src/app/domain/projects/project.repository.ts index d70a65f..1eaf6c4 100644 --- a/src/app/domain/projects/project.repository.ts +++ b/src/app/domain/projects/project.repository.ts @@ -7,4 +7,5 @@ export interface ProjectRepository { list(userId: string): Observable; get(projectId: string): Observable; update(id: string, data: Project | any): Observable; + delete(id: string): Observable; } diff --git a/src/app/infrastructure/projects/pb-project.repository.ts b/src/app/infrastructure/projects/pb-project.repository.ts index 8252067..103ae13 100644 --- a/src/app/infrastructure/projects/pb-project.repository.ts +++ b/src/app/infrastructure/projects/pb-project.repository.ts @@ -13,7 +13,7 @@ export class PbProjectRepository implements ProjectRepository { private pb = new PocketBase(environment.baseUrl); create(project: CreateProjectDto): Observable { - return from(this.pb.collection('projets').create(project)); + return from(this.pb.collection('projets').create(project)); } list(userId: string): Observable { return from( @@ -24,6 +24,9 @@ export class PbProjectRepository implements ProjectRepository { return from(this.pb.collection('projets').getOne(projectId)); } update(id: string, data: any): Observable { - return from(this.pb.collection('projets').update(id, data)); + return from(this.pb.collection('projets').update(id, data)); + } + delete(id: string): Observable { + return from(this.pb.collection('projets').delete(id)); } } diff --git a/src/app/shared/components/dialog-box/dialog-box.component.html b/src/app/shared/components/dialog-box/dialog-box.component.html new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/dialog-box/dialog-box.component.scss b/src/app/shared/components/dialog-box/dialog-box.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/dialog-box/dialog-box.component.spec.ts b/src/app/shared/components/dialog-box/dialog-box.component.spec.ts new file mode 100644 index 0000000..33bd733 --- /dev/null +++ b/src/app/shared/components/dialog-box/dialog-box.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DialogBoxComponent } from './dialog-box.component'; + +describe('DialogBoxComponent', () => { + let component: DialogBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DialogBoxComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DialogBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/dialog-box/dialog-box.component.ts b/src/app/shared/components/dialog-box/dialog-box.component.ts new file mode 100644 index 0000000..e78f31e --- /dev/null +++ b/src/app/shared/components/dialog-box/dialog-box.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { ActionType } from '@app/domain/action-type.util'; + +@Component({ + selector: 'app-dialog-box', + standalone: true, + imports: [], + templateUrl: './dialog-box.component.html', + styleUrl: './dialog-box.component.scss', +}) +export class DialogBoxComponent { + protected readonly ActionType = ActionType; +} diff --git a/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.html b/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.html index 5c76aad..8a9fef0 100644 --- a/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.html +++ b/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.html @@ -142,7 +142,7 @@
+ @if (isEditMode) { + + } +
} + + +@if (showDeleteDialog()) { + +} diff --git a/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.spec.ts b/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.spec.ts index a1c4b64..d2aba74 100644 --- a/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.spec.ts +++ b/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.spec.ts @@ -3,47 +3,145 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MyProfileUpdateProjectFormComponent } from './my-profile-update-project-form.component'; import { ToastrService } from 'ngx-toastr'; import { provideRouter } from '@angular/router'; -import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token'; -import { ProjectRepository } from '@app/domain/projects/project.repository'; -import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token'; -import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token'; -import { AuthRepository } from '@app/domain/authentification/auth.repository'; -import { ProfileRepository } from '@app/domain/profiles/profile.repository'; -import { mockAuthRepo } from '@app/testing/auth.mock'; -import { mockProfileRepo } from '@app/testing/profile.mock'; -import { mockProjectRepo } from '@app/testing/project.mock'; +import { mockProjects } from '@app/testing/project.mock'; import { mockToastR } from '@app/testing/toastr.mock'; +import { ProjectViewModel } from '@app/adapters/projects/project.presenter.model'; +import { ActionType } from '@app/domain/action-type.util'; +import { ProjectFacade } from '@app/adapters/projects/project.facade'; +import { AuthFacade } from '@app/adapters/authentification/auth.facade'; +import { mockAuthenticationFacade } from '@app/testing/adapters/authentification/auth.facade.mock'; +import { mockProjectFac } from '@app/testing/adapters/projects/project.facade.mock'; describe('MyProfileUpdateProjectFormComponent', () => { let component: MyProfileUpdateProjectFormComponent; let fixture: ComponentFixture; - let mockToastrService: jest.Mocked> = mockToastR; - let mockProjectRepository: jest.Mocked> = mockProjectRepo; + // 1. Mock ProjectFacade + const mockProjectFacade = mockProjectFac; - let mockAuthRepository: jest.Mocked> = mockAuthRepo; - let mockProfileRepository: jest.Mocked> = mockProfileRepo; + // 2. Mock AuthFacade + const mockAuthFacade = mockAuthenticationFacade; + + // Donnée de test + const mockProjectData: ProjectViewModel = mockProjects[0] as ProjectViewModel; beforeEach(async () => { + // Reset des mocks + mockProjectFacade.project.set(undefined); + mockProjectFacade.loading.set({ isLoading: false, action: ActionType.NONE }); + mockProjectFacade.error.set({ hasError: false, message: null }); + jest.clearAllMocks(); + await TestBed.configureTestingModule({ imports: [MyProfileUpdateProjectFormComponent], providers: [ provideRouter([]), - { provide: ToastrService, useValue: mockToastrService }, - { provide: PROJECT_REPOSITORY_TOKEN, useValue: mockProjectRepository }, - { provide: AUTH_REPOSITORY_TOKEN, useValue: mockAuthRepository }, - { provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository }, + { provide: ToastrService, useValue: mockToastR }, + { provide: ProjectFacade, useValue: mockProjectFacade }, + { provide: AuthFacade, useValue: mockAuthFacade }, ], }).compileComponents(); fixture = TestBed.createComponent(MyProfileUpdateProjectFormComponent); component = fixture.componentInstance; + // Initialisation de l'input requis fixture.componentRef.setInput('projectId', 'fakeId'); - fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('Mode création (projectId = "add")', () => { + beforeEach(() => { + fixture.componentRef.setInput('projectId', 'add'); + fixture.detectChanges(); + }); + + it('devrait créer un nouveau projet lors de la soumission', () => { + component.projectForm.patchValue({ + nom: 'Nouveau Projet', + description: 'Description', + lien: 'https://nouveau.com', + }); + + component.onSubmit(); + + expect(mockProjectFacade.create).toHaveBeenCalled(); + const callArgs = mockProjectFacade.create.mock.calls[0][0]; + expect(callArgs.nom).toBe('Nouveau Projet'); + expect(callArgs.utilisateur).toBe('user_001'); + }); + }); + + describe('Mode édition (projectId = ID valide)', () => { + beforeEach(() => { + // On simule que la façade a chargé un projet + mockProjectFacade.project.set(mockProjectData); + + fixture.componentRef.setInput('projectId', '1'); + fixture.detectChanges(); + }); + + it('devrait charger le projet existant', () => { + // L'appel doit avoir été fait grâce à l'effect qui détecte projectId='project-123' + expect(mockProjectFacade.loadOne).toHaveBeenCalledWith('1'); + }); + + it('devrait remplir le formulaire avec les données du projet', () => { + // Comme on a set le signal 'project' dans le beforeEach, le formulaire doit être rempli + expect(component.projectForm.get('nom')?.value).toBe('Portfolio Web 3D'); + expect(component.projectForm.get('description')?.value).toBe( + 'Un site web interactif utilisant Three.js et Angular 17 pour présenter un portfolio 3D.' + ); + }); + + it('devrait mettre à jour le projet lors de la soumission', () => { + component.projectForm.patchValue({ + nom: 'Projet Modifié', + }); + + component.onSubmit(); + + expect(mockProjectFacade.update).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + nom: 'Projet Modifié', + }) + ); + }); + }); + + describe('Suppression du projet', () => { + beforeEach(() => { + mockProjectFacade.project.set(mockProjectData); + fixture.componentRef.setInput('projectId', '1'); + fixture.detectChanges(); + }); + + it('devrait confirmer et exécuter la suppression', () => { + component.confirmDelete(); + expect(mockProjectFacade.delete).toHaveBeenCalledWith('1'); + }); + }); + + describe('Événements de sortie', () => { + it('devrait émettre formIsUpdated après suppression réussie', () => { + // CORRECTION : On définit un projet pour que le template puisse lire 'project().nom' sans planter + mockProjectFacade.project.set({ id: '123', nom: 'Projet à supprimer' } as any); + + const spy = jest.fn(); + component.formIsUpdated.subscribe(spy); + + // On ouvre la modale (qui affiche surement le nom du projet) + component.showDeleteDialog.set(true); + + // 1. Simuler la fin de suppression (succès) via le signal loading + mockProjectFacade.loading.set({ isLoading: false, action: ActionType.DELETE }); + mockProjectFacade.error.set({ hasError: false, message: null }); + + // C'est ici que ça plantait car le template essayait d'afficher le nom d'un projet undefined + fixture.detectChanges(); + + expect(component.showDeleteDialog()).toBe(false); + expect(spy).toHaveBeenCalledWith(null); + }); }); }); diff --git a/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.ts b/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.ts index f7ff3c5..c800fc7 100644 --- a/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.ts +++ b/src/app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component.ts @@ -1,4 +1,4 @@ -import { Component, effect, inject, input, output } from '@angular/core'; +import { Component, effect, inject, input, output, signal } from '@angular/core'; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { PaginatorModule } from 'primeng/paginator'; import { ProjectPictureFormComponent } from '@app/shared/components/project-picture-form/project-picture-form.component'; @@ -18,7 +18,7 @@ import { AuthFacade } from '@app/adapters/authentification/auth.facade'; export class MyProfileUpdateProjectFormComponent { projectId = input.required(); - private readonly projectFacade = new ProjectFacade(); + private readonly projectFacade = inject(ProjectFacade); protected readonly ActionType = ActionType; protected readonly project = this.projectFacade.project; protected readonly loading = this.projectFacade.loading; @@ -29,7 +29,7 @@ export class MyProfileUpdateProjectFormComponent { private readonly formBuilder = inject(FormBuilder); - protected projectForm = this.formBuilder.group({ + projectForm = this.formBuilder.group({ nom: new FormControl('', [Validators.required]), description: new FormControl('', [Validators.required]), lien: new FormControl(''), @@ -37,25 +37,37 @@ export class MyProfileUpdateProjectFormComponent { formIsUpdated = output(); - constructor() { - effect(() => { - if (!this.loading().isLoading) { - switch (this.loading().action) { - case ActionType.CREATE: - break; - case ActionType.UPDATE: - break; - } + // Gestion de la boîte de dialogue de suppression + showDeleteDialog = signal(false); - if (this.project() !== undefined) { - this.projectForm.setValue({ - nom: this.project().nom ?? '', - description: this.project().description ?? '', - lien: this.project().lien ?? '', - }); + constructor() { + effect( + () => { + if (!this.loading().isLoading) { + switch (this.loading().action) { + case ActionType.CREATE: + break; + case ActionType.UPDATE: + break; + case ActionType.DELETE: + if (!this.error().hasError) { + this.showDeleteDialog.set(false); + this.formIsUpdated.emit(null); + } + break; + } + + if (this.project() !== undefined) { + this.projectForm.setValue({ + nom: this.project().nom ?? '', + description: this.project().description ?? '', + lien: this.project().lien ?? '', + }); + } } - } - }); + }, + { allowSignalWrites: true } + ); effect( () => { @@ -96,4 +108,26 @@ export class MyProfileUpdateProjectFormComponent { this.projectFacade.create(projectDto); } } + + openDeleteDialog(): void { + this.showDeleteDialog.set(true); + } + + closeDeleteDialog(): void { + this.showDeleteDialog.set(false); + } + + confirmDelete(): void { + if (this.project()?.id) { + this.projectFacade.delete(this.project().id); + } + } + + get isEditMode(): boolean { + return this.projectId() !== null && this.projectId() !== 'add'.toLowerCase(); + } + + get isDeleteDisabled(): boolean { + return !this.isEditMode || this.loading().isLoading; + } } diff --git a/src/app/shared/components/project-picture-form/project-picture-form.component.spec.ts b/src/app/shared/components/project-picture-form/project-picture-form.component.spec.ts index 247fb3e..bef55f8 100644 --- a/src/app/shared/components/project-picture-form/project-picture-form.component.spec.ts +++ b/src/app/shared/components/project-picture-form/project-picture-form.component.spec.ts @@ -4,9 +4,9 @@ import { ProjectPictureFormComponent } from './project-picture-form.component'; import { mockProjects } from '@app/testing/project.mock'; import { mockFileManagerSvc } from '@app/testing/file-manager.service.mock'; import { mockProjectFac } from '@app/testing/adapters/projects/project.facade.mock'; -import { ProjectViewModel } from '../../../adapters/projects/project.presenter.model'; -import { ProjectFacade } from '../../../adapters/projects/project.facade'; -import { FileManagerService } from '../../../adapters/shared/services/file-manager.service'; +import { ProjectViewModel } from '@app/adapters/projects/project.presenter.model'; +import { ProjectFacade } from '@app/adapters/projects/project.facade'; +import { FileManagerService } from '@app/adapters/shared/services/file-manager.service'; import { ActionType } from '@app/domain/action-type.util'; describe('ProjectPictureFormComponent', () => { @@ -30,7 +30,7 @@ describe('ProjectPictureFormComponent', () => { mockProjectFacade.update.mockClear(); mockProjectFacade.loading.set({ isLoading: false, action: ActionType.NONE }); - mockProjectFacade.error.set({ hasError: false }); + mockProjectFacade.error.set({ hasError: false, message: null }); await TestBed.configureTestingModule({ imports: [ProjectPictureFormComponent], diff --git a/src/app/testing/adapters/authentification/auth.facade.mock.ts b/src/app/testing/adapters/authentification/auth.facade.mock.ts index 0bef9bc..29467e5 100644 --- a/src/app/testing/adapters/authentification/auth.facade.mock.ts +++ b/src/app/testing/adapters/authentification/auth.facade.mock.ts @@ -1,8 +1,10 @@ import { signal } from '@angular/core'; import { ActionType } from '@app/domain/action-type.util'; +import { mockUsers } from '@app/testing/user.mock'; export const mockAuthenticationFacade = { sendRequestPasswordReset: jest.fn(), loading: signal({ isLoading: false, action: ActionType.NONE }), error: signal({ hasError: false }), + user: signal(mockUsers[0]), }; diff --git a/src/app/testing/adapters/projects/project.facade.mock.ts b/src/app/testing/adapters/projects/project.facade.mock.ts index 1c46123..d08784a 100644 --- a/src/app/testing/adapters/projects/project.facade.mock.ts +++ b/src/app/testing/adapters/projects/project.facade.mock.ts @@ -1,8 +1,13 @@ import { signal } from '@angular/core'; import { ActionType } from '@app/domain/action-type.util'; +import { ProjectViewModel } from '@app/adapters/projects/project.presenter.model'; export const mockProjectFac = { - update: jest.fn(), + project: signal(undefined), loading: signal({ isLoading: false, action: ActionType.NONE }), - error: signal({ hasError: false }), + error: signal({ hasError: false, message: null }), + loadOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), }; diff --git a/src/app/testing/application/projects/delete-project.usecase.spec.ts b/src/app/testing/application/projects/delete-project.usecase.spec.ts new file mode 100644 index 0000000..3b4422d --- /dev/null +++ b/src/app/testing/application/projects/delete-project.usecase.spec.ts @@ -0,0 +1,20 @@ +import { FakeProjectRepository } from '@app/testing/domain/projects/fake-project.repository'; +import { DeleteProjectUseCase } from '@app/application/projects/delete-project.usecase'; + +describe('DeleteProjectUseCase', () => { + let useCase: DeleteProjectUseCase; + let repo: FakeProjectRepository; + + beforeEach(() => { + repo = new FakeProjectRepository(); + useCase = new DeleteProjectUseCase(repo); + }); + + it('doit retourné le vrai si le projet est supprimé', (done) => { + const projectId = '1'; + useCase.execute(projectId).subscribe((project) => { + expect(project).toBe(true); + done(); + }); + }); +}); diff --git a/src/app/testing/domain/projects/fake-project.repository.ts b/src/app/testing/domain/projects/fake-project.repository.ts index 3d726a3..f7d82ce 100644 --- a/src/app/testing/domain/projects/fake-project.repository.ts +++ b/src/app/testing/domain/projects/fake-project.repository.ts @@ -42,4 +42,9 @@ export class FakeProjectRepository implements ProjectRepository { this.projects[index] = updated; return of(updated); } + + delete(id: string): Observable { + const projects = this.projects.filter((currentProject) => currentProject.id != id); + return of(projects.length != this.projects.length); + } } diff --git a/src/app/testing/project.mock.ts b/src/app/testing/project.mock.ts index 9fe5c8b..812092d 100644 --- a/src/app/testing/project.mock.ts +++ b/src/app/testing/project.mock.ts @@ -64,4 +64,5 @@ export const mockProjectRepo = { list: jest.fn().mockReturnValue(of([])), get: jest.fn().mockReturnValue(of({} as Project)), update: jest.fn().mockReturnValue(of({} as Project)), + delete: jest.fn().mockReturnValue(of(true)), };