Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7575919ae | |||
|
|
25739d1c73 | ||
| 8d7b0b7db5 | |||
|
|
73ac08e97a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -135,3 +135,6 @@ logs/
|
||||
*.secrets
|
||||
.act.secrets
|
||||
.env
|
||||
|
||||
.vscode
|
||||
|
||||
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
.vscode/launch.json
vendored
20
.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
.vscode/tasks.json
vendored
42
.vscode/tasks.json
vendored
@@ -1,42 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "bash replace-prod-env.sh src/environments/environment.development.ts $ENV_URL && ng serve",
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface ProfileViewModel {
|
||||
settings?: SettingsProfileDto;
|
||||
slug?: string;
|
||||
userViewModel?: UserViewModel;
|
||||
mail?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface ProfileViewModelPaginated {
|
||||
|
||||
@@ -46,6 +46,7 @@ export class ProfilePresenter {
|
||||
settings,
|
||||
isProfileVisible,
|
||||
missingFields,
|
||||
phone: this.formatPhoneNumberForDisplay(profile.phone ? `${profile.phone.toString()}` : ''),
|
||||
};
|
||||
|
||||
const profileExpand = (profile as any) ? (profile as any).expand : { utilisateur: {} as User };
|
||||
@@ -58,7 +59,7 @@ export class ProfilePresenter {
|
||||
const slug = userSlug === '' ? profileId : userSlug.concat('-', profileId);
|
||||
const avatarUrl = userExpand.avatar
|
||||
? `${environment.baseUrl}/api/files/users/${profile.utilisateur}/${userExpand.avatar}?thumb=320x240`
|
||||
: `https://api.dicebear.com/9.x/initials/svg?seed=${userExpand.name}}`;
|
||||
: `https://api.dicebear.com/9.x/initials/svg?seed=${userExpand.name}`;
|
||||
|
||||
profileViewModel = {
|
||||
...profileViewModel,
|
||||
@@ -66,6 +67,7 @@ export class ProfilePresenter {
|
||||
slug,
|
||||
fullName: userExpand.name,
|
||||
avatarUrl,
|
||||
mail: userExpand.email,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,4 +106,31 @@ export class ProfilePresenter {
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
private cleanPhoneNumber(phone: string): string {
|
||||
if (!phone) return '';
|
||||
return phone.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
private formatPhoneNumberForDisplay(phone: string): string {
|
||||
if (!phone) return '';
|
||||
|
||||
const cleaned = this.cleanPhoneNumber(`0${phone}`);
|
||||
|
||||
// 9 chiffres : 06 xxx xx xx (3 + 2 + 2 + 2)
|
||||
if (cleaned.length === 9) {
|
||||
return `${cleaned.slice(0, 2)} ${cleaned.slice(2, 5)} ${cleaned.slice(5, 7)} ${cleaned.slice(7, 9)}`;
|
||||
}
|
||||
|
||||
// 10 chiffres : 06 xx xx xx xx (5 groupes de 2)
|
||||
if (cleaned.length === 10) {
|
||||
const groups: string[] = [];
|
||||
for (let i = 0; i < cleaned.length; i += 2) {
|
||||
groups.push(cleaned.slice(i, i + 2));
|
||||
}
|
||||
return groups.join(' ');
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProjectViewModel[]>([]);
|
||||
readonly project = signal<ProjectViewModel>({} 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,
|
||||
|
||||
10
src/app/application/projects/delete-project.usecase.ts
Normal file
10
src/app/application/projects/delete-project.usecase.ts
Normal file
@@ -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<boolean> {
|
||||
return this.repo.delete(projectId);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface Profile {
|
||||
estGeolocaliser?: boolean;
|
||||
partageMail?: boolean;
|
||||
partagePhone?: boolean;
|
||||
phone?: number;
|
||||
}
|
||||
|
||||
export interface ProfilePaginated {
|
||||
|
||||
@@ -7,4 +7,5 @@ export interface ProjectRepository {
|
||||
list(userId: string): Observable<Project[]>;
|
||||
get(projectId: string): Observable<Project>;
|
||||
update(id: string, data: Project | any): Observable<Project>;
|
||||
delete(id: string): Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { from, map, Observable } from 'rxjs';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { Injectable } from '@angular/core';
|
||||
import PocketBase, { ListResult } from 'pocketbase';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { environment } from '@env/environment';
|
||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class PbProjectRepository implements ProjectRepository {
|
||||
private pb = new PocketBase(environment.baseUrl);
|
||||
|
||||
create(project: CreateProjectDto): Observable<Project> {
|
||||
return from(this.pb.collection('projets').create<Project>(project));
|
||||
return from(this.pb.collection<Project>('projets').create<Project>(project));
|
||||
}
|
||||
list(userId: string): Observable<Project[]> {
|
||||
return from(
|
||||
@@ -24,6 +24,9 @@ export class PbProjectRepository implements ProjectRepository {
|
||||
return from(this.pb.collection<Project>('projets').getOne<Project>(projectId));
|
||||
}
|
||||
update(id: string, data: any): Observable<Project> {
|
||||
return from(this.pb.collection('projets').update<Project>(id, data));
|
||||
return from(this.pb.collection<Project>('projets').update<Project>(id, data));
|
||||
}
|
||||
delete(id: string): Observable<boolean> {
|
||||
return from(this.pb.collection<Project>('projets').delete(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DialogBoxComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DialogBoxComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DialogBoxComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
13
src/app/shared/components/dialog-box/dialog-box.component.ts
Normal file
13
src/app/shared/components/dialog-box/dialog-box.component.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -42,6 +42,135 @@
|
||||
<app-my-profile-update-cv-form [profile]="profile" />
|
||||
</div>
|
||||
|
||||
<!-- Section mes coordonnées -->
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 animate-fade-in animation-delay-100"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="w-10 h-10 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-purple-600 dark:text-purple-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Mes coordonnées</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Mail -->
|
||||
<div>
|
||||
<label for="mail" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Adresse email
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="mail"
|
||||
type="email"
|
||||
formControlName="mail"
|
||||
placeholder="mon-mail@domaine.extension"
|
||||
readonly
|
||||
[ngClass]="{
|
||||
'border-red-500 focus:ring-red-500': isEmailInvalid,
|
||||
'border-gray-300 dark:border-gray-600': !isEmailInvalid,
|
||||
}"
|
||||
class="w-full pl-10 pr-4 py-3 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
@if (isEmailInvalid) {
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ emailErrorMessage }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Téléphone -->
|
||||
<div>
|
||||
<label
|
||||
for="phone"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Numéro de téléphone
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="phone"
|
||||
type="text"
|
||||
formControlName="phone"
|
||||
placeholder="06 12 34 56 78"
|
||||
maxlength="14"
|
||||
[ngClass]="{
|
||||
'border-red-500 focus:ring-red-500': isPhoneInvalid,
|
||||
'border-gray-300 dark:border-gray-600': !isPhoneInvalid,
|
||||
}"
|
||||
class="w-full pl-10 pr-4 py-3 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
@if (isPhoneInvalid) {
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ phoneErrorMessage }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section À propos de moi -->
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 animate-fade-in animation-delay-100"
|
||||
|
||||
@@ -30,6 +30,8 @@ describe('MyProfileUpdateFormComponent', () => {
|
||||
bio: '',
|
||||
apropos: '',
|
||||
reseaux: { facebook: '', github: '', instagram: '', linkedIn: '', web: '', x: '', youTube: '' },
|
||||
phone: '123',
|
||||
mail: 'test@mail.com',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -69,6 +71,8 @@ describe('MyProfileUpdateFormComponent', () => {
|
||||
mockProfileData.secteur = 'technology';
|
||||
mockProfileData.bio = 'A passionate developer';
|
||||
mockProfileData.apropos = 'About me';
|
||||
mockProfileData.mail = 'test@mail.com';
|
||||
mockProfileData.phone = '123';
|
||||
component.profileForm.setValue(mockProfileData);
|
||||
|
||||
const spyUpdateProfile = jest.spyOn(component, 'onSubmit');
|
||||
|
||||
@@ -78,6 +78,11 @@ export class MyProfileUpdateFormComponent implements OnInit {
|
||||
),
|
||||
bio: new FormControl(this.profile.bio ? this.profile.bio.toLowerCase() : ''),
|
||||
apropos: new FormControl(this.profile.apropos ? this.profile.apropos.toLowerCase() : ''),
|
||||
mail: new FormControl(
|
||||
{ value: this.profile.mail ? this.profile.mail.toLowerCase() : '', disabled: true },
|
||||
[Validators.email]
|
||||
),
|
||||
phone: new FormControl(this.profile.phone ? this.profile.phone.toString().toLowerCase() : ''),
|
||||
reseaux: new FormGroup({
|
||||
facebook: new FormControl(
|
||||
this.profile.reseaux ? (this.profile.reseaux as any)['facebook'] : ''
|
||||
@@ -111,14 +116,36 @@ export class MyProfileUpdateFormComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Profile = {
|
||||
const data: Partial<Profile> = {
|
||||
profession: this.profileForm.getRawValue().profession,
|
||||
secteur: this.profileForm.getRawValue().secteur,
|
||||
apropos: this.profileForm.getRawValue().apropos,
|
||||
bio: this.profileForm.getRawValue().bio,
|
||||
phone: parseInt(this.profileForm.getRawValue().phone.replace(/\D/g, '')),
|
||||
reseaux: this.profileForm.getRawValue().reseaux,
|
||||
} as Profile;
|
||||
} as Partial<Profile>;
|
||||
|
||||
this.profileFacade.update(this.profile.id, data);
|
||||
}
|
||||
|
||||
get isEmailInvalid(): boolean {
|
||||
const mailControl = this.profileForm.get('mail');
|
||||
return !!(mailControl?.invalid && mailControl?.touched && mailControl?.value);
|
||||
}
|
||||
|
||||
get isPhoneInvalid(): boolean {
|
||||
const phoneControl = this.profileForm.get('phone');
|
||||
if (!phoneControl?.value) return false;
|
||||
const phone = phoneControl.value.replace(/\D/g, '');
|
||||
|
||||
return phoneControl.touched && (phone.length < 9 || !phone.startsWith('0'));
|
||||
}
|
||||
|
||||
get emailErrorMessage(): string {
|
||||
return 'Veuillez entrer une adresse email valide';
|
||||
}
|
||||
|
||||
get phoneErrorMessage(): string {
|
||||
return 'Le numéro doit contenir minimum 9 chiffres (ex: 06 xxx xx xx)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="projectForm.invalid"
|
||||
[disabled]="projectForm.invalid || loading().isLoading"
|
||||
class="flex-1 py-3 px-4 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white font-medium rounded-lg shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
@@ -156,10 +156,35 @@
|
||||
d="M7.707 10.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V6h5a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2h5v5.586l-1.293-1.293zM9 4a1 1 0 012 0v2H9V4z"
|
||||
/>
|
||||
</svg>
|
||||
Sauvegarder
|
||||
{{ isEditMode ? 'Mettre à jour' : 'Créer le projet' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (isEditMode) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openDeleteDialog()"
|
||||
[disabled]="isDeleteDisabled"
|
||||
class="sm:w-auto py-3 px-6 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Supprimer
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="formIsUpdated.emit(null)"
|
||||
@@ -187,3 +212,118 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Boîte de dialogue de confirmation de suppression -->
|
||||
@if (showDeleteDialog()) {
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
|
||||
(click)="closeDeleteDialog()"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Centre le modal -->
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"
|
||||
>​</span
|
||||
>
|
||||
|
||||
<!-- Contenu du modal -->
|
||||
<div
|
||||
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div
|
||||
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/20 sm:mx-0 sm:h-10 sm:w-10"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-red-600 dark:text-red-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3
|
||||
class="text-lg leading-6 font-medium text-gray-900 dark:text-white"
|
||||
id="modal-title"
|
||||
>
|
||||
Supprimer le projet
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Êtes-vous sûr de vouloir supprimer le projet
|
||||
<strong class="text-gray-900 dark:text-white">{{ project().nom }}</strong> ? Cette
|
||||
action est irréversible et toutes les données associées seront définitivement
|
||||
supprimées.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirmDelete()"
|
||||
[disabled]="loading().isLoading && loading().action === ActionType.DELETE"
|
||||
class="w-full inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-red-600 hover:bg-red-700 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
@if (loading().isLoading && loading().action === ActionType.DELETE) {
|
||||
<svg
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Suppression...
|
||||
} @else {
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeDeleteDialog()"
|
||||
[disabled]="loading().isLoading && loading().action === ActionType.DELETE"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-lg border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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<MyProfileUpdateProjectFormComponent>;
|
||||
|
||||
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
|
||||
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>> = mockProjectRepo;
|
||||
// 1. Mock ProjectFacade
|
||||
const mockProjectFacade = mockProjectFac;
|
||||
|
||||
let mockAuthRepository: jest.Mocked<Partial<AuthRepository>> = mockAuthRepo;
|
||||
let mockProfileRepository: jest.Mocked<Partial<ProfileRepository>> = 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | null>();
|
||||
|
||||
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<string | null>();
|
||||
|
||||
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<boolean>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
|
||||
@@ -1,56 +1,332 @@
|
||||
import { ProfilePresenter } from '@app/adapters/profiles/profile.presenter';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { mockProfiles } from '@app/testing/profile.mock';
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
import { UserViewModel } from '@app/adapters/users/user.presenter.model';
|
||||
import { mockUsers } from '@app/testing/user.mock';
|
||||
|
||||
describe('ProfilePresenter', () => {
|
||||
it('devrait transformer un Profile en ProfileViewModel', () => {
|
||||
const profile: Profile = mockProfiles[0];
|
||||
let presenter: ProfilePresenter;
|
||||
|
||||
const profilePresenter = new ProfilePresenter();
|
||||
const viewModel = profilePresenter.toViewModel(profile);
|
||||
const mockProfile: Profile = mockProfiles[0] as Profile;
|
||||
|
||||
expect(viewModel).toEqual({
|
||||
id: profile.id,
|
||||
fullName: '', // transformation OK
|
||||
isVerifiedLabel: '✅ Vérifié',
|
||||
estVerifier: true,
|
||||
coordonnees: { latitude: 0, longitude: 0 } as Coordinates,
|
||||
profession: 'Développeur Web'.toUpperCase(),
|
||||
reseaux: { linkedin: 'https://linkedin.com/in/test' },
|
||||
secteur: 'Informatique',
|
||||
utilisateur: 'user_abc',
|
||||
createdAtFormatted: new Date(profile.created).toLocaleDateString(),
|
||||
avatarUrl: '',
|
||||
apropos: 'Développeur Angular & Node.js',
|
||||
bio: 'Passionné de code.',
|
||||
cv: 'http://localhost:8090/api/files/profiles/1/cv.pdf',
|
||||
isProfileVisible: true,
|
||||
missingFields: [],
|
||||
projets: ['p1', 'p2'],
|
||||
settings: {
|
||||
showEmail: false,
|
||||
showPhone: false,
|
||||
allowGeolocation: false,
|
||||
isProfilePublic: false,
|
||||
},
|
||||
const mockUser: UserViewModel = mockUsers[0];
|
||||
|
||||
beforeEach(() => {
|
||||
presenter = new ProfilePresenter();
|
||||
});
|
||||
|
||||
describe('toViewModel', () => {
|
||||
it('devrait convertir un profil en ProfileViewModel', () => {
|
||||
const viewModel = presenter.toViewModel(mockProfile);
|
||||
|
||||
expect(viewModel).toBeDefined();
|
||||
expect(viewModel.id).toBe('1');
|
||||
expect(viewModel.profession).toBe('DÉVELOPPEUR WEB');
|
||||
expect(viewModel.secteur).toBe('Informatique');
|
||||
expect(viewModel.bio).toBe('Passionné de code.');
|
||||
expect(viewModel.apropos).toBe('Développeur Angular & Node.js');
|
||||
expect(viewModel.estVerifier).toBe(true);
|
||||
expect(viewModel.isVerifiedLabel).toBe('✅ Vérifié');
|
||||
});
|
||||
|
||||
it('devrait formater le numéro de téléphone à 10 chiffres', () => {
|
||||
const profileWithPhone = {
|
||||
...mockProfile,
|
||||
phone: 612345678,
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithPhone);
|
||||
|
||||
expect(viewModel.phone).toBe('06 12 34 56 78');
|
||||
});
|
||||
|
||||
it('devrait formater le numéro de téléphone à 9 chiffres', () => {
|
||||
const profileWithPhone = {
|
||||
...mockProfile,
|
||||
phone: 123456789,
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithPhone);
|
||||
|
||||
expect(viewModel.phone).toBe('01 23 45 67 89');
|
||||
});
|
||||
|
||||
it('devrait gérer un téléphone vide', () => {
|
||||
const profileWithoutPhone = {
|
||||
...mockProfile,
|
||||
phone: undefined,
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithoutPhone);
|
||||
|
||||
expect(viewModel.phone).toBe('');
|
||||
});
|
||||
|
||||
it('devrait utiliser la bio par défaut si non fournie', () => {
|
||||
const profileWithoutBio = {
|
||||
...mockProfile,
|
||||
bio: '',
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithoutBio);
|
||||
|
||||
expect(viewModel.bio).toContain('Trouve Ton Profil');
|
||||
expect(viewModel.bio).toContain('expertise');
|
||||
});
|
||||
|
||||
it("devrait générer l'URL du CV correctement", () => {
|
||||
const viewModel = presenter.toViewModel(mockProfile);
|
||||
|
||||
expect(viewModel.cv).toContain('/api/files/profiles/1/cv.pdf');
|
||||
});
|
||||
|
||||
it('devrait retourner une chaîne vide si pas de CV', () => {
|
||||
const profileWithoutCv = {
|
||||
...mockProfile,
|
||||
cv: '',
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithoutCv);
|
||||
|
||||
expect(viewModel.cv).toBe('');
|
||||
});
|
||||
|
||||
it('devrait définir les settings correctement', () => {
|
||||
const viewModel = presenter.toViewModel(mockProfile);
|
||||
|
||||
expect(viewModel.settings).toBeDefined();
|
||||
expect(viewModel.settings?.showEmail).toBe(false);
|
||||
expect(viewModel.settings?.showPhone).toBe(false);
|
||||
expect(viewModel.settings?.allowGeolocation).toBe(false);
|
||||
expect(viewModel.settings?.isProfilePublic).toBe(false);
|
||||
});
|
||||
|
||||
it('devrait définir les coordonnées correctement', () => {
|
||||
const viewModel = presenter.toViewModel(mockProfile);
|
||||
|
||||
expect(viewModel.coordonnees).toBeDefined();
|
||||
expect(viewModel.coordonnees?.latitude).toBe(0);
|
||||
expect(viewModel.coordonnees?.longitude).toBe(0);
|
||||
});
|
||||
|
||||
it("devrait gérer l'absence de coordonnées", () => {
|
||||
const profileWithoutCoords = {
|
||||
...mockProfile,
|
||||
coordonnees: undefined,
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithoutCoords);
|
||||
|
||||
expect(viewModel.coordonnees).toBeUndefined();
|
||||
});
|
||||
|
||||
it('devrait formater la date de création', () => {
|
||||
const viewModel = presenter.toViewModel(mockProfile);
|
||||
|
||||
expect(viewModel.createdAtFormatted).toBeDefined();
|
||||
expect(typeof viewModel.createdAtFormatted).toBe('string');
|
||||
});
|
||||
|
||||
it('devrait définir le label de vérification pour un profil non vérifié', () => {
|
||||
const unverifiedProfile = {
|
||||
...mockProfile,
|
||||
estVerifier: false,
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(unverifiedProfile);
|
||||
|
||||
expect(viewModel.isVerifiedLabel).toBe('❌ Non vérifié');
|
||||
});
|
||||
|
||||
describe('avec données utilisateur (expand)', () => {
|
||||
it('devrait inclure les informations utilisateur si disponibles', () => {
|
||||
const profileWithExpand = {
|
||||
...mockProfile,
|
||||
expand: {
|
||||
utilisateur: mockUser,
|
||||
},
|
||||
} as any;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithExpand);
|
||||
|
||||
expect(viewModel.fullName).toBe('foo');
|
||||
expect(viewModel.mail).toBe('foo@bar.com');
|
||||
expect(viewModel.avatarUrl).toContain(
|
||||
'http://localhost:8090/api/files/users/user_abc/foo.png?thumb=320x240'
|
||||
);
|
||||
expect(viewModel.userViewModel).toBeDefined();
|
||||
});
|
||||
|
||||
it("devrait générer un slug à partir du username et de l'ID", () => {
|
||||
const profileWithExpand = {
|
||||
...mockProfile,
|
||||
expand: {
|
||||
utilisateur: {
|
||||
...mockUser,
|
||||
slug: 'test-user',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithExpand);
|
||||
|
||||
expect(viewModel.slug).toBe('foo-user001-1');
|
||||
});
|
||||
|
||||
it("devrait utiliser l'ID du profil si pas de slug utilisateur", () => {
|
||||
const profileWithExpand = {
|
||||
...mockProfile,
|
||||
expand: {
|
||||
utilisateur: {
|
||||
...mockUser,
|
||||
slug: undefined,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithExpand);
|
||||
|
||||
expect(viewModel.slug).toBe('foo-user001-1');
|
||||
});
|
||||
|
||||
it("devrait générer une URL Dicebear si pas d'avatar", () => {
|
||||
const profileWithExpand = {
|
||||
...mockProfile,
|
||||
expand: {
|
||||
utilisateur: {
|
||||
...mockUser,
|
||||
avatar: undefined,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithExpand);
|
||||
|
||||
expect(viewModel.avatarUrl).toContain('dicebear.com');
|
||||
expect(viewModel.avatarUrl).toContain('foo');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('devrait retourner ❌ Non vérifié si estVerifier = false', () => {
|
||||
const profile = { ...mockProfiles[1], estVerifier: false };
|
||||
describe('toViewModels', () => {
|
||||
it('devrait convertir un tableau de profils', () => {
|
||||
const profiles = [mockProfile, { ...mockProfile, id: '456' }];
|
||||
const viewModels = presenter.toViewModels(profiles);
|
||||
|
||||
const profilePresenter = new ProfilePresenter();
|
||||
const viewModel = profilePresenter.toViewModel(profile);
|
||||
expect(viewModels).toHaveLength(2);
|
||||
expect(viewModels[0].id).toBe('1');
|
||||
expect(viewModels[1].id).toBe('456');
|
||||
});
|
||||
|
||||
expect(viewModel.isVerifiedLabel).toBe('❌ Non vérifié');
|
||||
it('devrait gérer un tableau vide', () => {
|
||||
const viewModels = presenter.toViewModels([]);
|
||||
|
||||
expect(viewModels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('devrait transformer un tableau complet', () => {
|
||||
const profilePresenter = new ProfilePresenter();
|
||||
const result = profilePresenter.toViewModels(mockProfiles);
|
||||
describe('toViewModelPaginated', () => {
|
||||
it('devrait convertir un ProfilePaginated en ProfileViewModelPaginated', () => {
|
||||
const paginatedProfiles: ProfilePaginated = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
totalPages: 5,
|
||||
totalItems: 50,
|
||||
items: [mockProfile],
|
||||
};
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].fullName).toBe('');
|
||||
const viewModel = presenter.toViewModelPaginated(paginatedProfiles);
|
||||
|
||||
expect(viewModel.page).toBe(1);
|
||||
expect(viewModel.perPage).toBe(10);
|
||||
expect(viewModel.totalPages).toBe(5);
|
||||
expect(viewModel.totalItems).toBe(50);
|
||||
expect(viewModel.items).toHaveLength(1);
|
||||
expect(viewModel.items[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProfileVisible (méthode privée via toViewModel)', () => {
|
||||
it('devrait marquer le profil comme visible si profession et secteur sont renseignés', () => {
|
||||
const viewModel = presenter.toViewModel(mockProfile);
|
||||
|
||||
expect(viewModel.isProfileVisible).toBe(true);
|
||||
expect(viewModel.missingFields).toEqual([]);
|
||||
});
|
||||
|
||||
it("devrait marquer le profil comme non visible si la profession n'est pas renseignée", () => {
|
||||
const profileWithoutProfession = {
|
||||
...mockProfile,
|
||||
profession: 'profession non renseignée',
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithoutProfession);
|
||||
|
||||
expect(viewModel.isProfileVisible).toBe(false);
|
||||
expect(viewModel.missingFields).toContain('profession');
|
||||
});
|
||||
|
||||
it("devrait marquer le profil comme non visible si le secteur n'est pas renseigné", () => {
|
||||
const profileWithoutSector = {
|
||||
...mockProfile,
|
||||
secteur: '',
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(profileWithoutSector);
|
||||
|
||||
expect(viewModel.isProfileVisible).toBe(false);
|
||||
expect(viewModel.missingFields).toContain("secteur d'activité");
|
||||
});
|
||||
|
||||
it('devrait lister tous les champs manquants', () => {
|
||||
const incompleteProfile = {
|
||||
...mockProfile,
|
||||
profession: 'profession non renseignée',
|
||||
secteur: '',
|
||||
} as Profile;
|
||||
|
||||
const viewModel = presenter.toViewModel(incompleteProfile);
|
||||
|
||||
expect(viewModel.isProfileVisible).toBe(false);
|
||||
expect(viewModel.missingFields).toContain('profession');
|
||||
expect(viewModel.missingFields).toContain("secteur d'activité");
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPhoneNumberForDisplay (méthode privée)', () => {
|
||||
it('devrait formater un numéro à 10 chiffres', () => {
|
||||
const profile = { ...mockProfile, phone: 612345678 } as Profile;
|
||||
const viewModel = presenter.toViewModel(profile);
|
||||
|
||||
expect(viewModel.phone).toBe('06 12 34 56 78');
|
||||
});
|
||||
|
||||
it('devrait formater un numéro à 9 chiffres', () => {
|
||||
const profile = { ...mockProfile, phone: 123456789 } as Profile;
|
||||
const viewModel = presenter.toViewModel(profile);
|
||||
|
||||
expect(viewModel.phone).toBe('01 23 45 67 89');
|
||||
});
|
||||
|
||||
it('devrait retourner le numéro tel quel si moins de 9 chiffres', () => {
|
||||
const profile = { ...mockProfile, phone: 1234567 } as Profile;
|
||||
const viewModel = presenter.toViewModel(profile);
|
||||
|
||||
expect(viewModel.phone).toBe('01234567');
|
||||
});
|
||||
|
||||
it('devrait retourner le numéro tel quel si plus de 10 chiffres', () => {
|
||||
const profile = { ...mockProfile, phone: 12345678901 } as Profile;
|
||||
const viewModel = presenter.toViewModel(profile);
|
||||
|
||||
expect(viewModel.phone).toBe('012345678901');
|
||||
});
|
||||
|
||||
it('devrait retourner une chaîne vide pour un téléphone null', () => {
|
||||
const profile = { ...mockProfile, phone: null } as any;
|
||||
const viewModel = presenter.toViewModel(profile);
|
||||
|
||||
expect(viewModel.phone).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProjectViewModel | undefined>(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(),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,4 +42,9 @@ export class FakeProjectRepository implements ProjectRepository {
|
||||
this.projects[index] = updated;
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<boolean> {
|
||||
const projects = this.projects.filter((currentProject) => currentProject.id != id);
|
||||
return of(projects.length != this.projects.length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const mockProfiles: Profile[] = [
|
||||
reseaux: JSON.parse('{"linkedin": "https://linkedin.com/in/test"}'),
|
||||
bio: 'Passionné de code.',
|
||||
cv: 'cv.pdf',
|
||||
phone: 123,
|
||||
projets: ['p1', 'p2'],
|
||||
apropos: 'Développeur Angular & Node.js',
|
||||
},
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user