ttp-refactoring #6

Merged
technostrea merged 6 commits from ttp-refactoring into main 2025-12-23 08:12:08 +00:00
193 changed files with 2574 additions and 1702 deletions

View File

@@ -18,7 +18,8 @@
"builder": "@angular-devkit/build-angular:application",
"options": {
"allowedCommonJsDependencies": [
"pocketbase"
"pocketbase",
"leaflet"
],
"outputPath": "dist/",
"index": "src/index.html",

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "trouve-ton-profile",
"version": "1.1.0",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trouve-ton-profile",
"version": "1.1.0",
"version": "1.3.1",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",
@@ -24,6 +24,7 @@
"@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@ngneat/until-destroy": "^10.0.0",
"browser-image-compression": "^2.0.2",
"express": "^4.18.2",
"leaflet": "^1.9.4",
"ng2-pdf-viewer": "^10.3.3",
@@ -8641,6 +8642,15 @@
"node": ">=8"
}
},
"node_modules/browser-image-compression": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
"license": "MIT",
"dependencies": {
"uzip": "0.20201231.0"
}
},
"node_modules/browserslist": {
"version": "4.27.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
@@ -19814,6 +19824,12 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/uzip": {
"version": "0.20201231.0",
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "trouve-ton-profile",
"version": "1.3.0",
"version": "1.3.1",
"scripts": {
"ng": "ng",
"start": "bash replace-prod-env.sh src/environments/environment.development.ts $ENV_URL && ng serve",
@@ -41,6 +41,7 @@
"@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@ngneat/until-destroy": "^10.0.0",
"browser-image-compression": "^2.0.2",
"express": "^4.18.2",
"leaflet": "^1.9.4",
"ng2-pdf-viewer": "^10.3.3",

View File

@@ -0,0 +1,195 @@
import { inject, Injectable, signal } from '@angular/core';
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
import { LoaderAction } from '@app/domain/loader-action.util';
import { ActionType } from '@app/domain/action-type.util';
import { ErrorResponse } from '@app/domain/error-response.util';
import { LoginUseCase } from '../../application/authentification/login.usecase';
import { RegisterUseCase } from '../../application/authentification/register.usecase';
import { LoginDto } from '@app/domain/authentification/dto/login-dto';
import { RegisterDto } from '@app/domain/authentification/dto/register-dto';
import { User } from '@app/domain/users/user.model';
import { AuthResponse } from '@app/domain/authentification/auth.repository';
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
import { ProfileFacade } from '../profiles/profile.facade';
import { SendVerificationEmailUsecase } from '../../application/authentification/send-verification-email.usecase';
import { LogoutUseCase } from '../../application/authentification/logout.usecase';
import { VerifyAuthenticatedUsecase } from '../../application/authentification/verify-authenticated.usecase';
import { VerifyEmailUseCase } from '../../application/authentification/verify-email.usecase';
import { GetCurrentUserUseCase } from '../../application/authentification/get-current-user.usecase';
import { SendRequestPasswordResetUsecase } from '../../application/authentification/send-request-password-reset.usecase';
import { FeedbackService } from '../shared/services/feedback.service';
import { first } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthFacade {
private readonly feedbackService = inject(FeedbackService);
private readonly authRepository = inject(AUTH_REPOSITORY_TOKEN);
private readonly profileFacade = new ProfileFacade();
private readonly loginUseCase = new LoginUseCase(this.authRepository);
private readonly registerUseCase = new RegisterUseCase(this.authRepository);
private readonly logoutUseCase = new LogoutUseCase(this.authRepository);
private readonly getUserUseCase = new GetCurrentUserUseCase(this.authRepository);
private readonly sendVerificationEmailUseCase = new SendVerificationEmailUsecase(
this.authRepository
);
private readonly verifyAuthenticatedUseCase = new VerifyAuthenticatedUsecase(this.authRepository);
private readonly verifyEmailUseCase = new VerifyEmailUseCase(this.authRepository);
private readonly senRequestPasswordResetUseCase = new SendRequestPasswordResetUsecase(
this.authRepository
);
readonly isAuthenticated = signal<boolean>(false);
readonly isEmailVerified = signal<boolean>(false);
readonly isVerificationEmailSent = signal<boolean>(false);
readonly isRequestPasswordSent = signal<boolean>(false);
readonly user = signal<User | undefined>(undefined);
readonly authResponse = signal<AuthResponse | undefined>(undefined);
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
readonly error = signal<ErrorResponse>({
action: ActionType.NONE,
hasError: false,
message: null,
});
login(loginDto: LoginDto) {
this.handleError(ActionType.READ, false, null, true);
let message = '';
this.loginUseCase
.execute(loginDto)
.pipe(first())
.subscribe({
next: async (res: AuthResponse) => {
this.authResponse.set(res);
this.getCurrentUser();
this.handleError(ActionType.READ, false, null, false);
if (!this.authResponse()!.isValid && !this.authResponse()?.record.verified) {
message = `Vous ne pouvez pas vous connecter sans valider la verification envoyé à cet adresse ${this.authResponse()?.record.email!}`;
this.feedbackService.notify(ActionType.READ, `${message}`);
return;
}
message = `Bienvenue parmi nous ${loginDto.email}!`;
this.feedbackService.notify(ActionType.READ, `${message}`);
},
error: (err) => {
this.handleError(ActionType.READ, true, err.message, false);
},
});
}
register(registerDto: RegisterDto) {
this.handleError(ActionType.CREATE, false, null, true);
let message = '';
this.registerUseCase
.execute(registerDto)
.pipe(first())
.subscribe({
next: (user) => {
this.getCurrentUser();
this.sendVerificationEmail(registerDto.email);
this.createDefaultProfile(user.id);
message = `Votre compte a bien été crée avec succès !\n Un mail vous a été envoyé à l'adresse ${registerDto.email} pour confirmer votre inscription.`;
this.feedbackService.notify(ActionType.CREATE, `${message}`);
this.handleError(ActionType.CREATE, false, null, false);
},
error: (err) => {
this.handleError(ActionType.CREATE, true, err.message, false);
},
});
}
logout() {
this.logoutUseCase.execute();
this.isAuthenticated.set(false);
this.getCurrentUser();
}
verifyAuthenticatedUser() {
this.isAuthenticated.set(this.verifyAuthenticatedUseCase.execute());
}
verifyEmail() {
this.isEmailVerified.set(this.verifyEmailUseCase.execute());
}
getCurrentUser() {
this.user.set(this.getUserUseCase.execute());
}
sendRequestPasswordReset(email: string) {
this.handleError(ActionType.CREATE, false, null, true);
let message = '';
this.senRequestPasswordResetUseCase
.execute(email)
.pipe(first())
.subscribe({
next: (res) => {
this.isRequestPasswordSent.set(res);
this.handleError(ActionType.CREATE, false, null, false);
message = `Un mail de réinitialisation vous a été envoyé à cette adresse mail : ${email}`;
this.feedbackService.notify(ActionType.CREATE, `${message}`);
},
error: (err) => {
this.handleError(ActionType.CREATE, true, err.message, false);
},
});
}
private sendVerificationEmail(email: string) {
this.handleError(ActionType.CREATE, false, null, true);
this.sendVerificationEmailUseCase
.execute(email)
.pipe(first())
.subscribe({
next: (res) => {
this.isVerificationEmailSent.set(res);
this.handleError(ActionType.CREATE, false, null, false);
},
error: (err) => {
this.handleError(ActionType.CREATE, true, err.message, false);
},
});
}
private createDefaultProfile(userId: string) {
const profileDto: ProfileDTO = {
profession: 'Profession non renseignée',
utilisateur: userId,
reseaux: {
facebook: '',
github: '',
instagram: '',
linkedIn: '',
web: '',
x: '',
youTube: '',
},
};
this.profileFacade.create(profileDto);
}
private handleError(
action: ActionType = ActionType.NONE,
hasError: boolean,
message: string | null = null,
isLoading = false
) {
this.error.set({ action, hasError, message });
this.loading.set({ action, isLoading });
if (hasError) {
this.feedbackService.notify(ActionType.READ, message!, true);
}
}
}

View File

@@ -1,5 +1,5 @@
import { inject, Injectable, signal } from '@angular/core';
import { GetCurrentLocationUseCase } from '@app/usecase/location/get-current-location.use-case';
import { GetCurrentLocationUseCase } from '../../application/location/get-current-location.use-case';
import { Coordinates } from '@app/domain/localisation/coordinates.model';
export interface LocationState {

View File

@@ -0,0 +1,219 @@
import { ListProfilesUseCase } from '../../application/profiles/list-profiles.usecase';
import { inject, Injectable, signal } from '@angular/core';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
import { ProfilePresenter } from '../profiles/profile.presenter';
import { ProfileViewModel, ProfileViewModelPaginated } from '../profiles/profile.presenter.model';
import { LoaderAction } from '@app/domain/loader-action.util';
import { ActionType } from '@app/domain/action-type.util';
import { ErrorResponse } from '@app/domain/error-response.util';
import { CreateProfileUseCase } from '../../application/profiles/create-profile.usecase';
import { UpdateProfileUseCase } from '../../application/profiles/update-profile.usecase';
import { GetProfileUseCase } from '../../application/profiles/get-profile.usecase';
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
import { SearchFilters } from '@app/domain/search/search-filters';
import { SearchService } from '@app/infrastructure/search/search.service';
import { UpdateCoordinateProfileUseCase } from '../../application/profiles/update-coordinate-profile.usecase';
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
import { UpdateSettingsProfileUseCase } from '../../application/profiles/update-settings-profile.usecase';
import { FeedbackService } from '../shared/services/feedback.service';
import { first, Subscription } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class ProfileFacade {
private readonly feedbackService = inject(FeedbackService);
private readonly profileRepository = inject(PROFILE_REPOSITORY_TOKEN);
private readonly searchService = inject(SearchService);
private readonly profilePresenter = new ProfilePresenter();
private listUseCase = new ListProfilesUseCase(this.profileRepository);
private createUseCase = new CreateProfileUseCase(this.profileRepository);
private updateUseCase = new UpdateProfileUseCase(this.profileRepository);
private updateCoordinateUseCase = new UpdateCoordinateProfileUseCase(this.profileRepository);
private updateSettingsUseCase = new UpdateSettingsProfileUseCase(this.profileRepository);
private getUseCase = new GetProfileUseCase(this.profileRepository);
readonly searchFilters = this.searchService.getFilters();
readonly profiles = signal<ProfileViewModel[]>([]);
readonly profilePaginated = signal<ProfileViewModelPaginated>({} as ProfileViewModelPaginated);
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
readonly loading = signal<LoaderAction>({
isLoading: false,
action: ActionType.NONE,
isDone: false,
});
readonly error = signal<ErrorResponse>({
action: ActionType.NONE,
hasError: false,
message: null,
});
private searchSubscription: Subscription | null = null;
load(search?: SearchFilters) {
if (this.searchSubscription) {
this.searchSubscription.unsubscribe();
this.searchSubscription = null;
}
this.handleError(ActionType.READ, false, null, true);
if (search === undefined || search === null) {
search = this.searchFilters();
}
this.searchSubscription = this.listUseCase.execute(search).subscribe({
next: (profilePaginated: ProfilePaginated) => {
const filters = {
...this.searchFilters(),
page: profilePaginated.page,
perPage: profilePaginated.perPage,
totalItems: profilePaginated.totalItems,
totalPages: profilePaginated.totalPages,
};
this.searchService.setFilters(filters);
this.searchFilters.set(filters);
const profileViewModelPaginated =
this.profilePresenter.toViewModelPaginated(profilePaginated);
this.profilePaginated.set(profileViewModelPaginated);
this.profiles.set(profileViewModelPaginated.items);
this.handleError(ActionType.READ, false, null, false);
this.searchSubscription = null;
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
this.searchSubscription = null;
},
});
}
loadOne(profileId: string) {
this.handleError(ActionType.READ, false, null, true);
this.getUseCase
.execute(profileId)
.pipe(first())
.subscribe({
next: (profile: Profile) => {
this.profile.set(this.profilePresenter.toViewModel(profile));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
});
}
loadOneByUserId(userId: string) {
this.handleError(ActionType.READ, false, null, true);
this.getUseCase
.executeByUserId(userId)
.pipe(first())
.subscribe({
next: (profile: Profile) => {
this.profile.set(this.profilePresenter.toViewModel(profile));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
});
}
create(profileDto: ProfileDTO) {
this.handleError(ActionType.CREATE, false, null, true);
let message = null;
this.createUseCase
.execute(profileDto)
.pipe(first())
.subscribe({
next: (profile: Profile) => {
this.profile.set(this.profilePresenter.toViewModel(profile));
this.handleError(ActionType.CREATE, false, null, false);
},
error: (err) => {
this.handleError(ActionType.CREATE, false, err, false);
},
});
}
update(profileId: string, profile: Partial<Profile>) {
this.handleError(ActionType.UPDATE, false, null, true);
this.updateUseCase
.execute(profileId, profile)
.pipe(first())
.subscribe({
next: (profile: Profile) => {
this.profile.set(this.profilePresenter.toViewModel(profile));
this.handleError(ActionType.UPDATE, false, null, false);
const message = `Vos informations personnelles ont bien été modifier !`;
this.feedbackService.notify(ActionType.UPDATE, message);
},
error: (err) => {
this.handleError(ActionType.UPDATE, false, err, false);
},
});
}
updateCoordinate(profileId: string, latitude: number, longitude: number) {
this.handleError(ActionType.UPDATE, false, null, true);
this.updateCoordinateUseCase
.execute(profileId, latitude, longitude)
.pipe(first())
.subscribe({
next: (profile: Profile) => {
this.profile.set(this.profilePresenter.toViewModel(profile));
this.handleError(ActionType.UPDATE, false, null, false);
const message = `Vos coordonnées géographique ont été enregistrés.`;
this.feedbackService.notify(ActionType.UPDATE, message);
},
error: (err) => {
this.handleError(ActionType.UPDATE, false, err, false);
},
});
}
updateSettings(profileId: string, settings: SettingsProfileDto) {
this.handleError(ActionType.UPDATE, false, null, true);
this.updateSettingsUseCase
.execute(profileId, settings)
.pipe(first())
.subscribe({
next: (profile: Profile) => {
this.profile.set(this.profilePresenter.toViewModel(profile));
this.handleError(ActionType.UPDATE, false, null, false, true);
const message = `Vos paramètres ont été enregistrés.`;
this.feedbackService.notify(ActionType.UPDATE, message);
},
error: (err) => {
this.handleError(ActionType.UPDATE, false, err, false);
},
});
}
private handleError(
action: ActionType = ActionType.NONE,
hasError: boolean,
message: string | null = null,
isLoading = false,
isDone = false
) {
this.error.set({ action, hasError, message });
this.loading.set({ action, isLoading, isDone });
if (hasError) {
this.feedbackService.notify(ActionType.READ, message!, true);
}
}
}

View File

@@ -1,6 +1,6 @@
import { Coordinates } from '@app/domain/localisation/coordinates.model';
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
import { UserViewModel } from '@app/ui/users/user.presenter.model';
import { UserViewModel } from '../users/user.presenter.model';
export interface ProfileViewModel {
id: string;

View File

@@ -1,15 +1,13 @@
import {
ProfileViewModel,
ProfileViewModelPaginated,
} from '@app/ui/profiles/profile.presenter.model';
import { ProfileViewModel, ProfileViewModelPaginated } from '../profiles/profile.presenter.model';
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
import { User } from '@app/domain/users/user.model';
import { environment } from '@env/environment';
import { UserPresenter } from '@app/ui/users/user.presenter';
import { UserPresenter } from '../users/user.presenter';
export class ProfilePresenter {
private readonly userPresenter = new UserPresenter();
private DEFAULT_BIO = `Je suis sur la plateforme Trouve Ton Profil pour partager mon expertise et mes compétences. N'hésitez pas à me contacter pour en savoir plus sur mon parcours et mes domaines d'intervention.`;
toViewModelPaginated(profilePaginated: ProfilePaginated): ProfileViewModelPaginated {
return { ...profilePaginated, items: this.toViewModels(profilePaginated.items) };
@@ -25,6 +23,8 @@ export class ProfilePresenter {
isProfilePublic: profile.estVisible ?? false,
};
const bio = profile.bio ? profile.bio : this.DEFAULT_BIO;
let profileViewModel: ProfileViewModel = {
id: profile.id,
fullName: '', // ❗ exemple volontaire
@@ -33,13 +33,13 @@ export class ProfilePresenter {
avatarUrl: ``,
estVerifier: profile.estVerifier,
utilisateur: profile.utilisateur,
profession: profile.profession,
profession: profile.profession.toUpperCase() ?? 'Profession non renseignée'.toUpperCase(),
secteur: profile.secteur,
reseaux: profile.reseaux,
apropos: profile.apropos,
projets: profile.projets,
cv: profile.cv,
bio: profile.bio,
cv: profile.cv ? `${environment.baseUrl}/api/files/profiles/${profile.id}/${profile.cv}` : '',
bio,
coordonnees: profile.coordonnees
? { latitude: profile!.coordonnees!.lat!, longitude: profile!.coordonnees!.lon! }
: undefined,
@@ -56,13 +56,16 @@ export class ProfilePresenter {
const userSlug = userViewModel.slug ?? '';
const profileId = profile.id ? profile.id : '';
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}}`;
profileViewModel = {
...profileViewModel,
userViewModel,
slug,
fullName: userExpand.name,
avatarUrl: `${environment.baseUrl}/api/files/users/${profile.utilisateur}/${userExpand.avatar}`,
avatarUrl,
};
}
@@ -78,7 +81,9 @@ export class ProfilePresenter {
return false;
}
const hasProfession = !!currentProfile.profession;
const hasProfession = currentProfile.profession
? currentProfile.profession.toLowerCase() !== 'profession non renseignée'
: false;
const hasSector = !!currentProfile.secteur;
return hasProfession && hasSector;
@@ -87,7 +92,10 @@ export class ProfilePresenter {
private missingFields(currentProfile: Profile) {
const missing: string[] = [];
if (!currentProfile?.profession) {
if (
!currentProfile?.profession ||
currentProfile.profession.toLowerCase() === 'profession non renseignée'
) {
missing.push('profession');
}
if (!currentProfile?.secteur) {

View File

@@ -0,0 +1,133 @@
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 { Project } from '@app/domain/projects/project.model';
import { ProjectViewModel } from '../projects/project.presenter.model';
import { ProjectPresenter } from '../projects/project.presenter';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
import { ErrorResponse } from '@app/domain/error-response.util';
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';
@Injectable({
providedIn: 'root',
})
export class ProjectFacade {
private readonly feedbackService = inject(FeedbackService);
private readonly projectRepo = inject(PROJECT_REPOSITORY_TOKEN);
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);
readonly projects = signal<ProjectViewModel[]>([]);
readonly project = signal<ProjectViewModel>({} as ProjectViewModel);
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
readonly error = signal<ErrorResponse>({
action: ActionType.NONE,
hasError: false,
message: null,
});
private readonly projectPresenter = new ProjectPresenter();
private projectSubscription: Subscription | null = null;
load(userId: string) {
if (this.projectSubscription) {
this.projectSubscription.unsubscribe();
this.projectSubscription = null;
}
this.handleError(ActionType.READ, false, null, true);
this.projectSubscription = this.listUseCase.execute(userId).subscribe({
next: (projects: Project[]) => {
this.projects.set(this.projectPresenter.toViewModels(projects));
this.handleError(ActionType.READ, false, null, false);
this.projectSubscription = null;
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
this.projectSubscription = null;
},
});
}
loadOne(projectId: string) {
this.handleError(ActionType.READ, false, null, true);
this.getUseCase
.execute(projectId)
.pipe(first())
.subscribe({
next: (project: Project) => {
this.project.set(this.projectPresenter.toViewModel(project));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
});
}
create(projectDto: CreateProjectDto) {
this.handleError(ActionType.CREATE, false, null, true);
this.createUseCase
.execute(projectDto)
.pipe(first())
.subscribe({
next: (project: Project) => {
this.project.set(this.projectPresenter.toViewModel(project));
this.projects.update((prev) => [...prev, this.projectPresenter.toViewModel(project)]);
this.handleError(ActionType.CREATE, false, null, false);
const message = `Le projet ${project.nom} a bien été créer !`;
this.feedbackService.notify(ActionType.UPDATE, message);
},
error: (err) => {
this.handleError(ActionType.CREATE, false, err, false);
},
});
}
update(userId: string, data: any) {
this.handleError(ActionType.UPDATE, false, null, true);
this.UpdateUseCase.execute(userId, data)
.pipe(first())
.subscribe({
next: (project: Project) => {
this.project.set(this.projectPresenter.toViewModel(project));
this.handleError(ActionType.UPDATE, false, null, false);
const message = `Les informations du projet ${project.nom} ont bien été modifier !`;
this.feedbackService.notify(ActionType.UPDATE, message);
},
error: (err) => {
this.handleError(ActionType.UPDATE, false, err, false);
},
});
}
private handleError(
action: ActionType = ActionType.NONE,
hasError: boolean,
message: string | null = null,
isLoading = false
) {
this.error.set({ action, hasError, message });
this.loading.set({ action, isLoading });
if (hasError) {
this.feedbackService.notify(ActionType.READ, message!, true);
}
}
}

View File

@@ -5,6 +5,6 @@ export interface ProjectViewModel {
nom: string;
lien: string;
description: string;
fichier: string[];
fichier: string;
utilisateur: string;
}

View File

@@ -1,16 +1,20 @@
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { ProjectViewModel } from '../projects/project.presenter.model';
import { Project } from '@app/domain/projects/project.model';
import { environment } from '@env/environment';
export class ProjectPresenter {
toViewModel(project: Project): ProjectViewModel {
const fichier = project.fichier
? `${environment.baseUrl}/api/files/projets/${project.id}/${project.fichier}`
: `https://api.dicebear.com/9.x/shapes/svg?seed=${project.nom}`;
return {
id: project.id,
created: project.created,
updated: project.updated,
nom: project.nom,
lien: project.lien,
description: project.description,
fichier: project.fichier,
description: project.description ?? 'Aucune description disponible.',
fichier,
utilisateur: project.utilisateur,
};
}
@@ -18,4 +22,6 @@ export class ProjectPresenter {
toViewModels(projects: Project[]): ProjectViewModel[] {
return projects.map(this.toViewModel);
}
private formatFichier(project: Project) {}
}

View File

@@ -1,16 +1,19 @@
import { inject, Injectable, signal } from '@angular/core';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { ListSectorUsecase } from '@app/usecase/sectors/list-sector.usecase';
import { GetSectorUseCase } from '@app/usecase/sectors/get-sector.usecase';
import { ListSectorUsecase } from '../../application/sectors/list-sector.usecase';
import { GetSectorUseCase } from '../../application/sectors/get-sector.usecase';
import { ActionType } from '@app/domain/action-type.util';
import { LoaderAction } from '@app/domain/loader-action.util';
import { ErrorResponse } from '@app/domain/error-response.util';
import { SectorPresenterModel } from '@app/ui/sectors/sector.presenter.model';
import { SectorPresenterModel } from '../sectors/sector.presenter.model';
import { Sector } from '@app/domain/sectors/sector.model';
import { SectorPresenter } from '@app/ui/sectors/sector.presenter';
import { SectorPresenter } from '../sectors/sector.presenter';
import { first, Subscription } from 'rxjs';
import { FeedbackService } from '../shared/services/feedback.service';
@Injectable()
export class SectorFacade {
private readonly feedbackService = inject(FeedbackService);
private readonly sectorRepo = inject(SECTOR_REPOSITORY_TOKEN);
private readonly listSectorUseCase = new ListSectorUsecase(this.sectorRepo);
@@ -27,31 +30,42 @@ export class SectorFacade {
});
private readonly sectorPresenter = new SectorPresenter();
private sectorSubscription: Subscription | null = null;
load() {
if (this.sectorSubscription) {
this.sectorSubscription.unsubscribe();
this.sectorSubscription = null;
}
this.handleError(ActionType.READ, false, null, true);
this.listSectorUseCase.execute().subscribe({
this.sectorSubscription = this.listSectorUseCase.execute().subscribe({
next: (sectors: Sector[]) => {
this.sectors.set(this.sectorPresenter.toViewModels(sectors));
this.handleError(ActionType.READ, false, null, false);
this.sectorSubscription = null;
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
this.sectorSubscription = null;
},
});
}
loadOne(sectorId: string) {
this.handleError(ActionType.READ, false, null, true);
this.getSectorUseCase.execute(sectorId).subscribe({
next: (sector: Sector) => {
this.sector.set(this.sectorPresenter.toViewModel(sector));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
});
this.getSectorUseCase
.execute(sectorId)
.pipe(first())
.subscribe({
next: (sector: Sector) => {
this.sector.set(this.sectorPresenter.toViewModel(sector));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
});
}
private handleError(
@@ -62,5 +76,9 @@ export class SectorFacade {
) {
this.error.set({ action, hasError, message });
this.loading.set({ action, isLoading });
if (hasError) {
this.feedbackService.notify(ActionType.READ, message!, true);
}
}
}

View File

@@ -1,4 +1,4 @@
import { SectorPresenterModel } from '@app/ui/sectors/sector.presenter.model';
import { SectorPresenterModel } from '../sectors/sector.presenter.model';
import { Sector } from '@app/domain/sectors/sector.model';
export class SectorPresenter {

View File

@@ -1,10 +1,10 @@
import { inject, Injectable, signal } from '@angular/core';
import { ThemeType, UserSettings } from '@app/domain/settings/setting.model';
import { GetSettingsUseCase } from '@app/usecase/settings/get-settings.usecase';
import { UpdateSettingsUseCase } from '@app/usecase/settings/update-settings.usecase';
import { GetSettingsUseCase } from '../../application/settings/get-settings.usecase';
import { UpdateSettingsUseCase } from '../../application/settings/update-settings.usecase';
import { SETTING_REPOSITORY_TOKEN } from '@app/infrastructure/settings/setting-repository.token';
import { ApplyThemeUsecase } from '@app/usecase/settings/apply-theme.usecase';
import { GetDefaultSystemThemeUsecase } from '@app/usecase/settings/get-default-system-theme.usecase';
import { ApplyThemeUsecase } from '../../application/settings/apply-theme.usecase';
import { GetDefaultSystemThemeUsecase } from '../../application/settings/get-default-system-theme.usecase';
@Injectable({ providedIn: 'root' })
export class SettingsFacade {

View File

@@ -0,0 +1,82 @@
import { inject, Injectable } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { ActionType } from '@app/domain/action-type.util';
@Injectable({
providedIn: 'root',
})
export class FeedbackService {
private readonly toastr = inject(ToastrService);
private readonly TOAST_CONFIG = {
closeButton: true,
progressAnimation: 'decreasing' as const,
progressBar: true,
};
/**
* @param action Le type d'action (UPDATE, CREATE...). Passer null pour une info générique.
* @param message Le message à afficher.
* @param hasError Si true, affiche une erreur générique.
*/
notify(action: ActionType | null, message: string, hasError: boolean = false): void {
if (hasError) {
const userFriendlyMessage = this.handleErrorMessage(message);
this.handleError(userFriendlyMessage);
} else {
this.handleSuccess(action, message);
}
}
private handleError(message: string): void {
this.toastr.error(`${message}`, `Erreur`, {
...this.TOAST_CONFIG,
disableTimeOut: true,
progressBar: false,
});
}
private handleSuccess(action: ActionType | null, message: string): void {
let title = 'Succès';
switch (action) {
case ActionType.UPDATE:
title = 'Mise à jour';
break;
case ActionType.CREATE:
title = 'Création';
break;
case ActionType.DELETE:
title = 'Suppression';
break;
}
this.toastr.success(message, title, this.TOAST_CONFIG);
}
private handleErrorMessage(messageError: string): string {
const lowerMessage = messageError.toLowerCase();
// Mapping des erreurs PocketBase courantes vers messages utilisateurs
const errorMessages: Record<string, string> = {
'failed to authenticate': `Votre email n'est pas valide ou votre mot de passe est incorrect`,
'failed to auth': `Échec de l'authentification. Vérifiez vos identifiants.`,
'invalid email': `L'adresse email n'est pas valide.`,
'invalid password': `Le mot de passe ne respecte pas les critères de sécurité.`,
'passwords do not match': `Les mots de passe saisis ne correspondent pas.`,
'record not found': `La ressource demandée n'existe pas.`,
'validation error': `Données invalides. Vérifiez les champs saisis.`,
'network error': `Problème de connexion. Vérifiez votre réseau.`,
};
// Recherche du premier match
for (const [errorKey, userMessage] of Object.entries(errorMessages)) {
if (lowerMessage.includes(errorKey)) {
return userMessage;
}
}
// Message par défaut
return `Une erreur s'est produite, veuillez réessayer ultérieurement`;
}
}

View File

@@ -0,0 +1,142 @@
import { Injectable, signal } from '@angular/core';
import imageCompression from 'browser-image-compression';
@Injectable({ providedIn: 'root' })
export class FileManagerService {
file = signal<File | null>(null);
imagePreviewUrl = signal<string | null>(null);
fileError = signal<string | null>(null);
isCompressing = signal<boolean>(false);
private readonly MAX_FILE_SIZE = 5 * 1024 * 1024; // 5Mo en bytes
private readonly ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private readonly ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Octets';
const k = 1024;
const sizes = ['Octets', 'Ko', 'Mo', 'Go'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
async onPictureChange($event: Event): Promise<void> {
const target = $event.target as HTMLInputElement;
const selectedFile = target?.files?.[0];
if (!selectedFile) {
return;
}
// Réinitialiser l'erreur
this.fileError.set(null);
// Vérifier le type de fichier
if (!this.ALLOWED_IMAGE_TYPES.includes(selectedFile.type)) {
this.resetFile();
this.fileError.set('Le fichier doit être une image (JPEG, PNG ou WebP)');
target.value = '';
return;
}
// Vérifier l'extension du fichier (sécurité supplémentaire)
const fileExtension = selectedFile.name.split('.').pop()?.toLowerCase();
if (!fileExtension || !this.ALLOWED_EXTENSIONS.includes(fileExtension)) {
this.resetFile();
this.fileError.set('Extension de fichier non autorisée');
target.value = '';
return;
}
// Vérifier la taille du fichier
if (selectedFile.size > this.MAX_FILE_SIZE) {
this.resetFile();
this.fileError.set(
`L'image est trop volumineuse (${this.formatFileSize(selectedFile.size)}). Taille maximale autorisée : 5 Mo`
);
target.value = '';
return;
}
// Configuration de la compression
const options = {
maxSizeMB: 1, // On vise une taille max de 1MB
maxWidthOrHeight: 1920, // On redimensionne si l'image est immense (4K+)
useWebWorker: true, // Ne pas bloquer l'interface
fileType: 'image/webp', // (Optionnel) Convertir en WebP pour le web (plus léger)
};
try {
this.isCompressing.set(true);
// Compression
const compressedFile = await imageCompression(selectedFile, options);
// Mise à jour des signaux avec le fichier optimisé
this.file.set(compressedFile);
this.readFile(compressedFile);
} catch (error) {
this.fileError.set("Impossible de compresser l'image.");
this.resetFile();
} finally {
this.isCompressing.set(false);
target.value = '';
}
}
onFileChange($event: Event): void {
const target = $event.target as HTMLInputElement;
const selectedFile = target?.files?.[0];
if (!selectedFile) {
return;
}
// Réinitialiser l'erreur
this.fileError.set(null);
// Vérifier le type de fichier
if (selectedFile.type !== 'application/pdf') {
this.fileError.set('Le fichier doit être au format PDF');
this.file.set(null);
target.value = ''; // Réinitialiser l'input
return;
}
// Vérifier la taille du fichier
if (selectedFile.size > this.MAX_FILE_SIZE) {
this.fileError.set(
`Le fichier est trop volumineux (${this.formatFileSize(selectedFile.size)}). Taille maximale autorisée : 5 Mo`
);
this.file.set(null);
target.value = ''; // Réinitialiser l'input
return;
}
this.file.set(selectedFile);
}
removeFile(): void {
this.resetFile();
}
resetFile(): void {
this.file.set(null);
this.imagePreviewUrl.set(null);
this.fileError.set(null);
}
private readFile(file: File): void {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewUrl.set(e.target?.result as string);
};
reader.onerror = () => {
this.fileError.set('Erreur lors de la lecture du fichier');
this.resetFile();
};
reader.readAsDataURL(file);
}
}

View File

@@ -0,0 +1,80 @@
import { inject, Injectable, signal } from '@angular/core';
import { USER_REPOSITORY_TOKEN } from '@app/infrastructure/users/user-repository.token';
import { GetUserUseCase } from '../../application/users/get-user.usecase';
import { UpdateUserUseCase } from '../../application/users/update-user.usecase';
import { LoaderAction } from '@app/domain/loader-action.util';
import { ActionType } from '@app/domain/action-type.util';
import { ErrorResponse } from '@app/domain/error-response.util';
import { UserViewModel } from '../users/user.presenter.model';
import { UserPresenter } from '../users/user.presenter';
import { first } from 'rxjs';
import { FeedbackService } from '../shared/services/feedback.service';
@Injectable({ providedIn: 'root' })
export class UserFacade {
private readonly feedbackService = inject(FeedbackService);
private readonly userRepository = inject(USER_REPOSITORY_TOKEN);
private readonly getUseCase = new GetUserUseCase(this.userRepository);
private readonly updateUseCase = new UpdateUserUseCase(this.userRepository);
readonly user = signal<UserViewModel>({} as UserViewModel);
readonly users = signal<UserViewModel[]>([]);
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
readonly error = signal<ErrorResponse>({
action: ActionType.NONE,
hasError: false,
message: null,
});
private readonly userPresenter = new UserPresenter();
loadOne(userId: string) {
this.handleError(ActionType.READ, false, null, true);
this.getUseCase
.execute(userId)
.pipe(first())
.subscribe({
next: (user) => {
this.user.set(this.userPresenter.toViewModel(user));
this.handleError(ActionType.READ, false, null, false);
},
error: (err) => {
this.handleError(ActionType.READ, false, err, false);
},
});
}
update(userId: string, user: Partial<UserViewModel>) {
this.handleError(ActionType.UPDATE, false, null, true);
this.updateUseCase
.execute(userId, user)
.pipe(first())
.subscribe({
next: (user) => {
this.user.set(this.userPresenter.toViewModel(user));
this.handleError(ActionType.UPDATE, false, null, false);
const message = `Votre profile a bien été modifier !`;
this.feedbackService.notify(ActionType.UPDATE, message);
},
error: (err) => {
this.handleError(ActionType.UPDATE, false, err, false);
},
});
}
private handleError(
action: ActionType = ActionType.NONE,
hasError: boolean,
message: string | null = null,
isLoading = false
) {
this.error.set({ action, hasError, message });
this.loading.set({ action, isLoading });
if (hasError) {
this.feedbackService.notify(ActionType.READ, message!, true);
}
}
}

View File

@@ -1,20 +1,26 @@
import { UserViewModel } from '@app/ui/users/user.presenter.model';
import { UserViewModel } from '../users/user.presenter.model';
import { User } from '@app/domain/users/user.model';
import { environment } from '@env/environment';
export class UserPresenter {
private DEFAULT_VALUE = 'Non renséigné';
toViewModel(user: User): UserViewModel {
const slug = user.name
? this.generateProfileSlug(user.name, user.id)
: this.generateProfileSlug('Non renséigné');
: this.generateProfileSlug(this.DEFAULT_VALUE);
const avatar = user.avatar
? `${environment.baseUrl}/api/files/users/${user.id}/${user.avatar}?thumb=320x240`
: `https://api.dicebear.com/9.x/initials/svg?seed=${user.name ? user.name : user.username ? user.username : this.DEFAULT_VALUE}`;
let userViewModel: UserViewModel = {
id: user.id,
username: user.username,
username: user.username ?? this.DEFAULT_VALUE,
verified: user.verified,
emailVisibility: user.emailVisibility,
email: user.email,
name: user.name,
avatar: user.avatar,
name: user.name ? user.name : this.DEFAULT_VALUE,
avatar,
slug,
isUserVisible: this.isUserVisible(user),
missingFields: this.missingFields(user),

View File

@@ -8,6 +8,8 @@ import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { SettingRepository } from '@app/domain/settings/setting.repository';
import { mockSettingRepo } from '@app/testing/setting.mock';
import { SETTING_REPOSITORY_TOKEN } from '@app/infrastructure/settings/setting-repository.token';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('AppComponent', () => {
let component: AppComponent;
@@ -41,6 +43,7 @@ describe('AppComponent', () => {
imports: [AppComponent],
providers: [
provideRouter([]),
{ provide: ToastrService, useValue: mockToastR },
{ provide: AUTH_REPOSITORY_TOKEN, useValue: mockAuthRepository },
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepo },
{ provide: SETTING_REPOSITORY_TOKEN, useValue: mockSettingRepository },

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { NavBarComponent } from '@app/shared/components/nav-bar/nav-bar.component';
import { FooterComponent } from '@app/shared/components/footer/footer.component';
import { SettingsFacade } from '@app/ui/settings/settings.facade';
import { SettingsFacade } from './adapters/settings/settings.facade';
@Component({
selector: 'app-root',

View File

@@ -1,4 +1,4 @@
import { ApplicationConfig } from '@angular/core';
import { ApplicationConfig, ErrorHandler } from '@angular/core';
import {
PreloadAllModules,
provideRouter,
@@ -26,6 +26,7 @@ import { WEB_SHARE_SERVICE_TOKEN } from '@app/infrastructure/shareData/web-share
import { WebShareService } from '@app/infrastructure/shareData/web-share.service';
import { SETTING_REPOSITORY_TOKEN } from '@app/infrastructure/settings/setting-repository.token';
import { LocalSettingRepository } from '@app/infrastructure/settings/local-setting.repository';
import { GlobalErrorHandler } from '@app/infrastructure/handlers/global-error-handler';
export const appConfig: ApplicationConfig = {
providers: [
@@ -48,6 +49,7 @@ export const appConfig: ApplicationConfig = {
{ provide: AUTH_REPOSITORY_TOKEN, useExisting: PbAuthRepository },
{ provide: WEB_SHARE_SERVICE_TOKEN, useExisting: WebShareService },
{ provide: SETTING_REPOSITORY_TOKEN, useClass: LocalSettingRepository },
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
provideToastr({
timeOut: 10000,
positionClass: 'toast-top-right',

View File

@@ -5,23 +5,23 @@ export const routes: Routes = [
{
path: '',
title: 'Accueil',
loadChildren: () => import('@app/routes/home/home.module').then((m) => m.HomeModule),
loadChildren: () => import('./views/home/home.module').then((m) => m.HomeModule),
},
{
path: 'home',
title: 'Accueil',
loadChildren: () => import('@app/routes/home/home.module').then((m) => m.HomeModule),
loadChildren: () => import('./views/home/home.module').then((m) => m.HomeModule),
},
{
path: 'profiles',
title: 'Liste des profiles',
loadChildren: () => import('@app/routes/profile/profile.module').then((m) => m.ProfileModule),
loadChildren: () => import('./views/profile/profile.module').then((m) => m.ProfileModule),
},
{
path: 'auth',
title: 'Authentification',
loadChildren: () =>
import('@app/routes/authentification/authentification.module').then(
import('./views/authentification/authentification.module').then(
(m) => m.AuthentificationModule
),
},
@@ -30,13 +30,12 @@ export const routes: Routes = [
title: 'Mon profile',
canActivate: [authGuard],
loadChildren: () =>
import('@app/routes/my-profile/my-profile.module').then((m) => m.MyProfileModule),
import('./views/my-profile/my-profile.module').then((m) => m.MyProfileModule),
},
{
path: 'not-found',
title: 'Page non trouvée',
loadChildren: () =>
import('@app/routes/not-found/not-found.module').then((m) => m.NotFoundModule),
loadChildren: () => import('./views/not-found/not-found.module').then((m) => m.NotFoundModule),
},
{ path: '', redirectTo: '/', pathMatch: 'full' },
{ path: '**', redirectTo: '/not-found' },

View File

@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { authGuard } from './auth.guard';
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { AuthFacade } from '../../../adapters/authentification/auth.facade';
describe('authGuard', () => {
// 1. Définition des variables pour les Mocks

View File

@@ -1,6 +1,6 @@
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { AuthFacade } from '../../../adapters/authentification/auth.facade';
export const authGuard: CanActivateFn = (route, state) => {
const authFacade = inject(AuthFacade);

View File

@@ -1,45 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';
describe('ThemeService', () => {
let service: ThemeService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ThemeService],
});
service = TestBed.inject(ThemeService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
// Test de la valeur initiale
it('should have initial value "null"', () => {
// Avec les Signals, on accède à la valeur en exécutant la fonction : signal()
expect(service.darkModeSignal()).toBe('null');
});
// Test du basculement vers Dark
it('should switch to "dark" when updateDarkMode is called and current is "null"', () => {
// Action
service.updateDarkMode();
// Vérification
expect(service.darkModeSignal()).toBe('dark');
});
// Test du basculement inverse (Dark vers Null)
it('should switch back to "null" when updateDarkMode is called and current is "dark"', () => {
// 1. Préparation : On force l'état à 'dark' pour tester ce cas précis
service.darkModeSignal.set('dark');
// 2. Action
service.updateDarkMode();
// 3. Vérification
expect(service.darkModeSignal()).toBe('null');
});
});

View File

@@ -1,12 +0,0 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ThemeService {
darkModeSignal = signal<string>('null');
updateDarkMode() {
this.darkModeSignal.update((value) => (value === 'dark' ? 'null' : 'dark'));
}
}

View File

@@ -0,0 +1,7 @@
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
OFF = 4,
}

View File

@@ -5,6 +5,6 @@ export interface Project {
nom: string;
lien: string;
description: string;
fichier: string[];
fichier: string;
utilisateur: string;
}

View File

@@ -0,0 +1,31 @@
import { ErrorHandler, inject, Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { LoggerService } from '@app/infrastructure/shared/logger.service';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
// On utilise inject() pour éviter les soucis de dépendance circulaire dans le constructeur
private logger = inject(LoggerService);
handleError(error: any): void {
let message = '';
let stackTrace = '';
if (error instanceof HttpErrorResponse) {
// Erreur serveur
message = `Erreur Serveur: ${error.status} ${error.message}`;
} else if (error instanceof Error) {
// Erreur Client (JavaScript)
message = `Erreur Client: ${error.message}`;
stackTrace = error.stack || '';
} else {
message = error;
}
// On loggue l'erreur via notre service
this.logger.error(message, stackTrace);
// Optionnel : Relancer l'erreur pour qu'elle apparaisse quand même dans la console du navigateur en DEV
throw error;
}
}

View File

@@ -0,0 +1,47 @@
import { environment } from '@env/environment';
import { LogLevel } from '@app/domain/log-level';
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LoggerService {
private level: LogLevel = environment.production ? LogLevel.ERROR : LogLevel.DEBUG;
debug(msg: string, ...optionalParams: any[]) {
this.log(LogLevel.DEBUG, msg, optionalParams);
}
info(msg: string, ...optionalParams: any[]) {
this.log(LogLevel.INFO, msg, optionalParams);
}
warn(msg: string, ...optionalParams: any[]) {
this.log(LogLevel.WARN, msg, optionalParams);
}
error(msg: string, ...optionalParams: any[]) {
this.log(LogLevel.ERROR, msg, optionalParams);
// TODO: Ici, vous pourriez envoyer l'erreur vers votre backend PocketBase ou Sentry
// this.sendToBackend(msg, optionalParams);
}
private log(level: LogLevel, msg: string, params: any[]) {
if (level >= this.level) {
switch (level) {
case LogLevel.DEBUG:
console.debug('%c[DEBUG]:', 'color: blue', msg, ...params);
break;
case LogLevel.INFO:
console.info('%c[INFO]:', 'color: green', msg, ...params);
break;
case LogLevel.WARN:
console.warn('%c[WARN]:', 'color: orange', msg, ...params);
break;
case LogLevel.ERROR:
console.error('%c[ERROR]:', 'color: red', msg, ...params);
break;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { Component, Input, output } from '@angular/core';
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-btn-loading',

View File

@@ -5,6 +5,8 @@ import { provideRouter } from '@angular/router';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { SectorRepository } from '@app/domain/sectors/sector.repository';
import { mockSectorRepo } from '@app/testing/sector.mock';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('ChipsComponent', () => {
let component: ChipsComponent;
@@ -18,6 +20,7 @@ describe('ChipsComponent', () => {
providers: [
provideRouter([]),
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepository },
{ provide: ToastrService, useValue: mockToastR },
],
}).compileComponents();

View File

@@ -1,7 +1,7 @@
import { Component, inject, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Component, inject, input, OnChanges, SimpleChanges } from '@angular/core';
import { TitleCasePipe } from '@angular/common';
import { UntilDestroy } from '@ngneat/until-destroy';
import { SectorFacade } from '@app/ui/sectors/sector.facade';
import { SectorFacade } from '@app/adapters/sectors/sector.facade';
@Component({
selector: 'app-chips',
@@ -13,7 +13,7 @@ import { SectorFacade } from '@app/ui/sectors/sector.facade';
})
@UntilDestroy()
export class ChipsComponent implements OnChanges {
@Input({ required: true }) sectorId: string | null = null;
sectorId = input.required<string>();
private readonly sectorFacade = inject(SectorFacade);
protected sector = this.sectorFacade.sector;
@@ -21,8 +21,8 @@ export class ChipsComponent implements OnChanges {
protected readonly error = this.sectorFacade.error;
ngOnChanges(changes: SimpleChanges) {
if (this.sectorId && !this.loading().isLoading) {
this.sectorFacade.loadOne(this.sectorId);
if (this.sectorId() && !this.loading().isLoading) {
this.sectorFacade.loadOne(this.sectorId());
}
}
}

View File

@@ -13,7 +13,7 @@ import {
import { Coordinates } from '@app/domain/localisation/coordinates.model';
import * as L from 'leaflet';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
export interface MapMarker {
id: string;
@@ -187,7 +187,7 @@ export class MapComponent implements OnInit, AfterViewInit, OnDestroy {
// Image (Avatar)
if (marker.profile!.avatarUrl) {
const img = document.createElement('img');
img.src = marker.profile!.avatarUrl;
img.src = marker.profile!.avatarUrl.replace('320x240', '100x100');
img.className = 'w-16 h-16 rounded-full object-cover border-2 border-indigo-500 shadow-sm';
popupContainer.appendChild(img);
} else {

View File

@@ -1,12 +1,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyProfileProjectItemComponent } from './my-profile-project-item.component';
import { of } from 'rxjs';
import { Project } from '@app/domain/projects/project.model';
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { provideRouter } from '@angular/router';
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
import { mockProjectRepo } from '@app/testing/project.mock';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('MyProfileProjectItemComponent', () => {
let component: MyProfileProjectItemComponent;
@@ -20,11 +20,15 @@ describe('MyProfileProjectItemComponent', () => {
providers: [
provideRouter([]),
{ provide: PROJECT_REPOSITORY_TOKEN, useValue: mockProjectRepository },
{ provide: ToastrService, useValue: mockToastR },
],
}).compileComponents();
fixture = TestBed.createComponent(MyProfileProjectItemComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('projectId', 'fakeId');
fixture.detectChanges();
await fixture.whenStable();

View File

@@ -1,7 +1,7 @@
import { Component, Input, OnInit } from '@angular/core';
import { Component, input, OnInit } from '@angular/core';
import { environment } from '@env/environment';
import { RouterLink } from '@angular/router';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { ProjectFacade } from '@app/adapters/projects/project.facade';
@Component({
selector: 'app-my-profile-project-item',
@@ -12,12 +12,12 @@ import { ProjectFacade } from '@app/ui/projects/project.facade';
})
export class MyProfileProjectItemComponent implements OnInit {
protected readonly environment = environment;
@Input({ required: true }) projectId = '';
projectId = input.required<string>();
private readonly projectFacade = new ProjectFacade();
protected project = this.projectFacade.project;
ngOnInit(): void {
this.projectFacade.loadOne(this.projectId);
this.projectFacade.loadOne(this.projectId());
}
}

View File

@@ -5,6 +5,8 @@ import { ProjectRepository } from '@app/domain/projects/project.repository';
import { provideRouter } from '@angular/router';
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
import { mockProfileRepo } from '@app/testing/profile.mock';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('MyProfileProjectListComponent', () => {
let component: MyProfileProjectListComponent;
@@ -17,6 +19,7 @@ describe('MyProfileProjectListComponent', () => {
providers: [
provideRouter([]),
{ provide: PROJECT_REPOSITORY_TOKEN, useValue: mockProjectRepository },
{ provide: ToastrService, useValue: mockToastR },
],
}).compileComponents();

View File

@@ -1,9 +1,9 @@
import { Component, Input, OnInit, signal } from '@angular/core';
import { Component, input, OnInit, signal } from '@angular/core';
import { PaginatorModule } from 'primeng/paginator';
import { ReactiveFormsModule } from '@angular/forms';
import { UntilDestroy } from '@ngneat/until-destroy';
import { MyProfileUpdateProjectFormComponent } from '@app/shared/components/my-profile-update-project-form/my-profile-update-project-form.component';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { ProjectFacade } from '@app/adapters/projects/project.facade';
@Component({
selector: 'app-my-profile-project-list',
@@ -14,8 +14,8 @@ import { ProjectFacade } from '@app/ui/projects/project.facade';
})
@UntilDestroy()
export class MyProfileProjectListComponent implements OnInit {
@Input({ required: true }) projectIds: string[] = [];
@Input({ required: true }) userId = '';
projectIds = input.required<string[]>();
userId = input<string>('');
protected projectIdSelected = signal<string | null>(null);
@@ -23,7 +23,7 @@ export class MyProfileProjectListComponent implements OnInit {
protected projects = this.projectFacade.projects;
ngOnInit(): void {
this.projectFacade.load(this.userId);
this.projectFacade.load(this.userId());
}
onProjectFormSubmitted($event: string | null) {

View File

@@ -1,64 +1,115 @@
<div class="flex max-sm:flex-col flex-row max-w-sm:space-y-2 space-x-2 justify-around items-center">
@if (file != null) {
<div class="flex-col flex space-y-2 justify-center items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5 cursor-pointer text-red-600"
(click)="file = null"
>
<title>Supprimer le fichier</title>
<path
fill-rule="evenodd"
d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z"
clip-rule="evenodd"
/>
</svg>
<img src="assets/images/pdf.svg" alt="pdf" class="max-w-sm max-h-16" />
<small>{{ file.name }}</small>
</div>
}
<label
for="uploadFile1"
class="flex justify-center items-center space-x-2 bg-gray-800 hover:bg-gray-700 text-white text-base px-3 py-1 outline-none rounded w-max cursor-pointer font-[sans-serif]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 mr-2 fill-white inline"
viewBox="0 0 32 32"
>
<path
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
data-original="#000000"
/>
<path
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
data-original="#000000"
/>
</svg>
<small class="text-xs">Selectionner un fichier .pdf</small>
<input
type="file"
id="uploadFile1"
class="hidden"
accept="application/pdf"
(change)="onFileChange($event)"
/>
</label>
</div>
@if (file != null) {
<button
type="button"
[ngClass]="{ 'bg-purple-600': file != null }"
class="!mt-2 px-6 py-2 w-full bg-[#333] hover:bg-[#444] text-sm text-white mx-auto block"
(click)="onSubmit()"
>
Mettre à jour mon CV
</button>
@if (loading().isLoading) {
<app-loading message="Téléchargement du CV" />
} @else {
<ng-container *ngTemplateOutlet="cvForm" />
}
<ng-template #cvForm>
<div class="space-y-4 max-w-md mx-auto">
<!-- Zone de sélection/affichage du fichier -->
<div
class="flex flex-col sm:flex-row sm:space-x-4 space-y-4 sm:space-y-0 items-center justify-center"
>
<!-- Aperçu du fichier sélectionné -->
@if (file(); as file) {
<div
class="flex flex-col space-y-2 items-center p-4 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800"
>
<div class="relative">
<img src="assets/images/pdf.svg" alt="PDF" class="w-16 h-16" />
<button
type="button"
(click)="fileManagerService.removeFile()"
class="absolute -top-2 -right-2 p-1 bg-red-600 hover:bg-red-700 rounded-full text-white transition-colors"
aria-label="Supprimer le fichier"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<span
class="text-sm text-gray-700 dark:text-gray-300 text-center break-all max-w-[200px]"
>
{{ file.name }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ fileManagerService.formatFileSize(file.size) }}
</span>
</div>
}
<!-- Bouton de sélection -->
<label
for="uploadFile1"
class="flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-800 hover:bg-gray-700 text-white px-6 py-3 rounded-lg cursor-pointer transition-colors w-full sm:w-auto"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 fill-white" viewBox="0 0 32 32">
<path
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
/>
<path
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
/>
</svg>
<span class="text-sm text-center">
{{ file() != null ? 'Changer le fichier' : 'Sélectionner un fichier PDF' }}
</span>
<input
type="file"
id="uploadFile1"
class="hidden"
accept="application/pdf"
(change)="fileManagerService.onFileChange($event)"
/>
</label>
</div>
<!-- Message d'erreur -->
@if (fileError()) {
<div
class="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
clip-rule="evenodd"
/>
</svg>
<p class="text-sm text-red-800 dark:text-red-200">{{ fileError() }}</p>
</div>
}
<!-- Information sur la taille maximale -->
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
Format accepté : PDF | Taille maximale : 5 Mo
</p>
<!-- Bouton de soumission -->
@if (file() != null && !fileError()) {
<button
type="button"
(click)="onSubmit()"
[disabled]="!canSubmit"
class="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
Mettre à jour mon CV
</button>
}
</div>
</ng-template>

View File

@@ -1,38 +1,128 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyProfileUpdateCvFormComponent } from './my-profile-update-cv-form.component';
import { ToastrService } from 'ngx-toastr';
import { provideRouter } from '@angular/router';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { mockProfileRepo } from '@app/testing/profile.mock';
import { mockToastR } from '@app/testing/toastr.mock';
import { mockFileManagerSvc } from '@app/testing/file-manager.service.mock';
import { ActionType } from '@app/domain/action-type.util';
import { ProfileFacade } from '../../../adapters/profiles/profile.facade';
import { FileManagerService } from '../../../adapters/shared/services/file-manager.service';
import { mockProfileFac } from '@app/testing/adapters/profiles/profile.facade.mock';
import { mockProfiles } from '@app/testing/profile.mock';
describe('MyProfileUpdateCvFormComponent', () => {
let component: MyProfileUpdateCvFormComponent;
let fixture: ComponentFixture<MyProfileUpdateCvFormComponent>;
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>> = mockProfileRepo;
// 1. Mock FileManagerService
// On reprend la logique des tests précédents pour ce service
let mockFileManagerService = mockFileManagerSvc;
// 2. Mock ProfileFacade
const mockProfileFacade = mockProfileFac;
// Donnée de test
const mockProfile = mockProfiles[0];
beforeEach(async () => {
// Reset des mocks
mockFileManagerService.file.set(null);
mockFileManagerService.fileError.set(null);
mockFileManagerService.resetFile.mockClear();
mockProfileFacade.update.mockClear();
mockProfileFacade.loading.set({ isLoading: false, action: ActionType.NONE });
mockProfileFacade.error.set({ hasError: false });
await TestBed.configureTestingModule({
imports: [MyProfileUpdateCvFormComponent],
providers: [
provideRouter([]),
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProjectRepository },
{ provide: ToastrService, useValue: mockToastrService },
// On injecte nos mocks à la place des vrais services
{ provide: FileManagerService, useValue: mockFileManagerService },
{ provide: ProfileFacade, useValue: mockProfileFacade },
],
}).compileComponents();
fixture = TestBed.createComponent(MyProfileUpdateCvFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
// Initialisation de l'input requis
fixture.componentRef.setInput('profile', mockProfile);
fixture.detectChanges();
});
it('should create', () => {
it('devrait créer le composant', () => {
expect(component).toBeTruthy();
});
describe('Validation (canSubmit)', () => {
it('devrait être false par défaut (pas de fichier)', () => {
expect(component.canSubmit).toBe(false);
});
it('devrait être true si un fichier est valide', () => {
mockFileManagerService.file.set(new File([''], 'cv.pdf'));
expect(component.canSubmit).toBe(true);
});
it('devrait être false si une erreur de fichier existe', () => {
mockFileManagerService.file.set(new File([''], 'cv.exe')); // Fichier présent...
mockFileManagerService.fileError.set('Type invalide'); // ...mais erreur
expect(component.canSubmit).toBe(false);
});
it('devrait être false si un chargement est en cours', () => {
mockFileManagerService.file.set(new File([''], 'cv.pdf'));
mockProfileFacade.loading.set({ isLoading: true, action: ActionType.UPDATE });
expect(component.canSubmit).toBe(false);
});
});
describe('Soumission (onSubmit)', () => {
it('devrait appeler facade.update avec FormData', () => {
const validFile = new File(['dummy content'], 'cv.pdf', { type: 'application/pdf' });
mockFileManagerService.file.set(validFile);
component.onSubmit();
expect(mockProfileFacade.update).toHaveBeenCalledTimes(1);
const args = mockProfileFacade.update.mock.calls[0];
expect(args[0]).toBe('1'); // ID du profil
expect(args[1]).toBeInstanceOf(FormData);
expect(args[1].get('cv')).toBe(validFile);
});
it('ne devrait rien faire si pas de fichier', () => {
mockFileManagerService.file.set(null);
component.onSubmit();
expect(mockProfileFacade.update).not.toHaveBeenCalled();
});
});
describe('Effets (Reset après succès)', () => {
it('devrait réinitialiser le fichier quand le chargement se termine sans erreur', () => {
// 1. État initial : chargement en cours
mockProfileFacade.loading.set({ isLoading: true, action: ActionType.UPDATE });
fixture.detectChanges();
// 2. État final : chargement terminé (isLoading: false)
mockProfileFacade.loading.set({ isLoading: false, action: ActionType.UPDATE });
mockProfileFacade.error.set({ hasError: false }); // Pas d'erreur
fixture.detectChanges(); // Déclenche l'effect
expect(mockFileManagerService.resetFile).toHaveBeenCalled();
});
it('ne devrait PAS réinitialiser si il y a une erreur', () => {
mockProfileFacade.loading.set({ isLoading: false, action: ActionType.UPDATE });
mockProfileFacade.error.set({ hasError: true }); // Erreur présente
fixture.detectChanges();
expect(mockFileManagerService.resetFile).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,58 +1,59 @@
import { Component, effect, inject, Input } from '@angular/core';
import { NgClass } from '@angular/common';
import { ToastrService } from 'ngx-toastr';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { Component, effect, inject, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
import { ProfileFacade } from '@app/adapters/profiles/profile.facade';
import { ActionType } from '@app/domain/action-type.util';
import { Profile } from '@app/domain/profiles/profile.model';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { FileManagerService } from '@app/adapters/shared/services/file-manager.service';
@Component({
selector: 'app-my-profile-update-cv-form',
standalone: true,
imports: [NgClass],
imports: [LoadingComponent, NgTemplateOutlet],
templateUrl: './my-profile-update-cv-form.component.html',
styleUrl: './my-profile-update-cv-form.component.scss',
})
export class MyProfileUpdateCvFormComponent {
@Input({ required: true }) profile: ProfileViewModel | undefined = undefined;
readonly fileManagerService = inject(FileManagerService);
profile = input.required<ProfileViewModel>();
private readonly toastrService = inject(ToastrService);
file = this.fileManagerService.file; // Variable to store file
fileError = this.fileManagerService.fileError;
file: File | null = null; // Variable to store file
private readonly profileFacade = inject(ProfileFacade);
private readonly profileFacade = new ProfileFacade();
protected readonly loading = this.profileFacade.loading;
protected readonly error = this.profileFacade.error;
readonly loading = this.profileFacade.loading;
readonly error = this.profileFacade.error;
constructor() {
effect(() => {
switch (this.loading().action) {
case ActionType.UPDATE:
if (!this.loading() && !this.error().hasError) {
//this.authService.updateUser();
this.toastrService.success(` Votre CV a bien été modifier !`, `Mise à jour`, {
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
});
if (!this.loading().isLoading && !this.error().hasError) {
this.fileManagerService.resetFile();
}
break;
}
});
}
onSubmit() {
if (this.file != null) {
onSubmit(): void {
const file = this.file();
if (file && !this.fileError()) {
const formData = new FormData();
formData.append('cv', this.file); // "avatar" est le nom du champ dans PocketBase
this.profileFacade.update(this.profile?.id!, formData as Partial<Profile>);
formData.append('cv', file);
this.profileFacade.update(this.profile()?.id!, formData as Partial<Profile>);
}
}
onFileChange($event: Event) {
const target: HTMLInputElement = $event.target as HTMLInputElement;
if (target?.files?.[0]) {
this.file = target.files[0];
}
get canSubmit(): boolean {
return (
this.file() != null &&
!this.fileError() &&
!this.error().hasError &&
!this.loading().isLoading
);
}
}

View File

@@ -8,12 +8,11 @@ import {
} from '@angular/forms';
import { UntilDestroy } from '@ngneat/until-destroy';
import { MyProfileUpdateCvFormComponent } from '@app/shared/components/my-profile-update-cv-form/my-profile-update-cv-form.component';
import { ToastrService } from 'ngx-toastr';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
import { ProfileFacade } from '@app/adapters/profiles/profile.facade';
import { ActionType } from '@app/domain/action-type.util';
import { Profile } from '@app/domain/profiles/profile.model';
import { SectorFacade } from '@app/ui/sectors/sector.facade';
import { SectorFacade } from '@app/adapters/sectors/sector.facade';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
@@ -32,7 +31,6 @@ import { LoadingComponent } from '@app/shared/components/loading/loading.compone
})
@UntilDestroy()
export class MyProfileUpdateFormComponent implements OnInit {
private readonly toastrService = inject(ToastrService);
protected readonly ActionType = ActionType;
@Input({ required: true }) profile: ProfileViewModel = {} as ProfileViewModel;
@@ -50,14 +48,10 @@ export class MyProfileUpdateFormComponent implements OnInit {
protected readonly sectorError = this.sectorFacade.error;
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.UPDATE:
message = `Vos informations personnelles ont bien été modifier !`;
this.customToast(ActionType.UPDATE, message);
break;
}
}
@@ -127,29 +121,4 @@ export class MyProfileUpdateFormComponent implements OnInit {
this.profileFacade.update(this.profile.id, data);
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
this.toastrService.success(
`${message}`,
`${action === ActionType.UPDATE ? 'Mise à jour' : ''}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
}
}

View File

@@ -1,4 +1,4 @@
@if (projectId) {
@if (projectId()) {
<div class="space-y-6">
<!-- Section Image du projet -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 animate-fade-in">
@@ -22,7 +22,7 @@
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Image du projet</h3>
</div>
@if (projectId === 'add'.toLowerCase()) {
@if (projectId() === 'add'.toLowerCase()) {
<app-project-picture-form [project]="undefined" />
} @else {
<app-project-picture-form [project]="project()" />

View File

@@ -5,8 +5,6 @@ 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 { of } from 'rxjs';
import { Project } from '@app/domain/projects/project.model';
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';
@@ -40,6 +38,8 @@ describe('MyProfileUpdateProjectFormComponent', () => {
fixture = TestBed.createComponent(MyProfileUpdateProjectFormComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('projectId', 'fakeId');
fixture.detectChanges();
});

View File

@@ -1,22 +1,12 @@
import {
Component,
effect,
inject,
Input,
OnChanges,
OnInit,
output,
SimpleChanges,
} from '@angular/core';
import { Component, effect, inject, input, output } 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';
import { ToastrService } from 'ngx-toastr';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { ProjectFacade } from '@app/adapters/projects/project.facade';
import { CreateProjectDto } from '@app/domain/projects/dto/create-project.dto';
import { ActionType } from '@app/domain/action-type.util';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { AuthFacade } from '@app/adapters/authentification/auth.facade';
@Component({
selector: 'app-my-profile-update-project-form',
@@ -25,10 +15,8 @@ import { AuthFacade } from '@app/ui/authentification/auth.facade';
templateUrl: './my-profile-update-project-form.component.html',
styleUrl: './my-profile-update-project-form.component.scss',
})
export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
@Input({ required: true }) projectId: string | null = null;
private readonly toastrService = inject(ToastrService);
export class MyProfileUpdateProjectFormComponent {
projectId = input.required<string | null>();
private readonly projectFacade = new ProjectFacade();
protected readonly ActionType = ActionType;
@@ -50,17 +38,12 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
formIsUpdated = output<string | null>();
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.CREATE:
message = `Le projet ${this.projectForm.getRawValue().nom} a bien été créer !`;
this.customToast(ActionType.CREATE, message);
break;
case ActionType.UPDATE:
message = `Les informations du projet ${this.projectForm.getRawValue().nom} ont bien été modifier !`;
this.customToast(ActionType.UPDATE, message);
break;
}
@@ -73,20 +56,26 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
}
}
});
}
ngOnInit(): void {
if (this.projectId == 'add'.toLowerCase()) {
this.projectForm.setValue({
nom: '',
description: '',
lien: '',
});
}
if (this.projectId != null && this.projectId != 'add'.toLowerCase()) {
this.projectFacade.loadOne(this.projectId);
}
effect(
() => {
const id = this.projectId();
if (id) {
if (id.toLowerCase() === 'add') {
// Mode Création : On vide le formulaire
this.projectForm.reset({
nom: '',
description: '',
lien: '',
});
} else {
// Mode Édition : On charge les données
this.projectFacade.loadOne(id);
}
}
},
{ allowSignalWrites: true }
);
}
onSubmit() {
@@ -94,7 +83,7 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
return;
}
if (this.projectId != null && this.projectId != 'add'.toLowerCase()) {
if (this.projectId() != null && this.projectId() != 'add'.toLowerCase()) {
// Update
this.projectFacade.update(this.project()!.id, this.projectForm.getRawValue());
} else {
@@ -107,35 +96,4 @@ export class MyProfileUpdateProjectFormComponent implements OnInit, OnChanges {
this.projectFacade.create(projectDto);
}
}
ngOnChanges(changes: SimpleChanges): void {
this.projectId = changes['projectId'].currentValue;
this.ngOnInit();
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
this.formIsUpdated.emit(this.project()!.id);
this.toastrService.success(
`${message}`,
`${action === ActionType.UPDATE ? 'Mise à jour' : 'Nouveau projet'}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
}
}

View File

@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NavBarComponent } from './nav-bar.component';
import { ThemeService } from '@app/core/services/theme/theme.service';
import { provideRouter } from '@angular/router';
import { User } from '@app/domain/users/user.model';
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
@@ -10,12 +9,12 @@ import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-r
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { mockProfileRepo } from '@app/testing/profile.mock';
import { mockAuthRepo } from '@app/testing/auth.mock';
import { mockThemeService } from '@app/testing/theme.mock';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('NavBarComponent', () => {
let component: NavBarComponent;
let fixture: ComponentFixture<NavBarComponent>;
let mockTheme: jest.Mocked<Partial<ThemeService>> = mockThemeService;
let mockAuthRepository: jest.Mocked<Partial<AuthRepository>> = mockAuthRepo;
let mockProfileRepository: jest.Mocked<Partial<ProfileRepository>> = mockProfileRepo;
@@ -37,7 +36,7 @@ describe('NavBarComponent', () => {
imports: [NavBarComponent],
providers: [
provideRouter([]),
{ provide: ThemeService, useValue: mockTheme },
{ provide: ToastrService, useValue: mockToastR },
{ provide: AUTH_REPOSITORY_TOKEN, useValue: mockAuthRepository },
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
],
@@ -51,9 +50,4 @@ describe('NavBarComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call themeService.updateDarkMode when toggleDarkMode called', () => {
component.toggleDarkMode();
expect(mockThemeService.updateDarkMode).toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,8 @@
import { Component, inject, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ThemeService } from '@app/core/services/theme/theme.service';
import { UntilDestroy } from '@ngneat/until-destroy';
import { environment } from '@env/environment';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { AuthFacade } from '@app/adapters/authentification/auth.facade';
@Component({
selector: 'app-nav-bar',
@@ -14,7 +13,6 @@ import { AuthFacade } from '@app/ui/authentification/auth.facade';
})
@UntilDestroy()
export class NavBarComponent implements OnInit {
protected themeService: ThemeService = inject(ThemeService);
protected readonly environment = environment;
protected authFacade = inject(AuthFacade);
@@ -23,10 +21,6 @@ export class NavBarComponent implements OnInit {
readonly isEmailVerified = this.authFacade.isEmailVerified;
readonly user = this.authFacade.user;
toggleDarkMode() {
this.themeService.updateDarkMode();
}
ngOnInit(): void {
this.authFacade.verifyEmail();
this.authFacade.verifyAuthenticatedUser();

View File

@@ -4,22 +4,13 @@
>
<!-- Image du projet -->
<div class="relative h-48 overflow-hidden bg-gray-100 dark:bg-gray-700">
@if (project.fichier) {
<img
alt="{{ project.nom }}"
class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-110"
src="{{ environment.baseUrl }}/api/files/projets/{{ project.id }}/{{ project.fichier }}"
loading="lazy"
/>
} @else {
<img
alt="{{ project.nom }}"
class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-110 opacity-60"
src="https://api.dicebear.com/9.x/shapes/svg?seed={{ project.nom }}"
loading="lazy"
/>
}
<img
alt="{{ project.nom }}"
class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-110"
ngSrc="{{ project.fichier }}"
loading="lazy"
fill
/>
<!-- Overlay gradient au hover -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"
@@ -37,7 +28,7 @@
<!-- Description -->
<p class="text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
{{ project.description || 'Aucune description disponible.' }}
{{ project.description }}
</p>
<!-- Lien vers le projet -->

View File

@@ -1,11 +1,12 @@
import { Component, Input } from '@angular/core';
import { environment } from '@env/environment';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { ProjectViewModel } from '@app/adapters/projects/project.presenter.model';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-project-item',
standalone: true,
imports: [],
imports: [NgOptimizedImage],
templateUrl: './project-item.component.html',
styleUrl: './project-item.component.scss',
})

View File

@@ -5,6 +5,8 @@ import { ProjectRepository } from '@app/domain/projects/project.repository';
import { provideRouter } from '@angular/router';
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
import { mockProjectRepo } from '@app/testing/project.mock';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('ProjectListComponent', () => {
let component: ProjectListComponent;
@@ -18,6 +20,7 @@ describe('ProjectListComponent', () => {
providers: [
provideRouter([]),
{ provide: PROJECT_REPOSITORY_TOKEN, useValue: mockProjectRepository },
{ provide: ToastrService, useValue: mockToastR },
],
}).compileComponents();

View File

@@ -1,7 +1,7 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Component, input, OnChanges, SimpleChanges } from '@angular/core';
import { UntilDestroy } from '@ngneat/until-destroy';
import { ProjectItemComponent } from '@app/shared/components/project-item/project-item.component';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { ProjectFacade } from '@app/adapters/projects/project.facade';
@Component({
selector: 'app-project-list',
@@ -12,13 +12,13 @@ import { ProjectFacade } from '@app/ui/projects/project.facade';
})
@UntilDestroy()
export class ProjectListComponent implements OnChanges {
@Input({ required: true }) userProjectId = '';
userProjectId = input.required<string>({});
private readonly projectFacade = new ProjectFacade();
protected projects = this.projectFacade.projects;
ngOnChanges(changes: SimpleChanges) {
this.projectFacade.load(this.userProjectId);
this.projectFacade.load(this.userProjectId());
}
}

View File

@@ -5,86 +5,124 @@
}
<ng-template #content>
<div class="w-full text-center">
<h3 class="font-ubuntu w-full text-start font-bold text-xl uppercase dark:text-white mb-4">
<div class="space-y-6 max-w-md mx-auto">
<!-- Titre -->
<h3 class="font-ubuntu font-bold text-xl uppercase dark:text-white text-center sm:text-start">
Aperçu du projet
</h3>
<div
class="w-40 h-40 rounded-full inline-flex items-center justify-center bg-gray-200 text-gray-400"
>
@if (imagePreviewUrl !== null && project !== undefined) {
<img
alt="nouveau-projet"
class="object-cover object-center h-full w-full"
[src]="imagePreviewUrl"
loading="lazy"
/>
} @else if (project !== undefined) {
@if (project.fichier) {
<!-- Prévisualisation de l'image -->
<div class="flex flex-col items-center space-y-4">
<div class="relative">
<div
class="w-40 h-40 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex items-center justify-center shadow-lg"
>
<img
alt="{{ project!.nom }}"
[alt]="project()?.nom || 'nouveau-projet'"
class="object-cover object-center h-full w-full"
src="{{ environment.baseUrl }}/api/files/projets/{{ project.id }}/{{ project.fichier }}"
[src]="currentImageUrl"
loading="lazy"
/>
} @else {
<img
alt="nouveau-projet"
class="object-cover object-center h-full w-full"
src="https://api.dicebear.com/9.x/shapes/svg?seed={{ project.nom }}"
loading="lazy"
/>
}
}
</div>
@if (project === undefined) {
<img
alt="nouveau-projet"
class="object-cover object-center h-full w-full"
src="https://api.dicebear.com/9.x/shapes/svg?seed=nouveau-projet"
loading="lazy"
/>
<!-- Bouton de suppression si preview existe -->
@if (imagePreviewUrl !== null) {
<button
type="button"
(click)="fileManagerService.removeFile()"
class="absolute -top-2 -right-2 p-2 bg-red-600 hover:bg-red-700 rounded-full text-white transition-colors shadow-lg"
aria-label="Supprimer l'image"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
}
</div>
<!-- Informations sur le fichier sélectionné -->
@if (file(); as file) {
<div class="text-center space-y-1">
<p class="text-sm text-gray-700 dark:text-gray-300 font-medium break-all max-w-[250px]">
{{ file.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ fileManagerService.formatFileSize(file.size) }}
</p>
</div>
}
</div>
<!-- Message d'erreur -->
@if (fileError()) {
<div
class="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
clip-rule="evenodd"
/>
</svg>
<p class="text-sm text-red-800 dark:text-red-200">{{ fileError() }}</p>
</div>
}
<!-- Bouton de sélection -->
<label
for="uploadFile1"
class="flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-800 hover:bg-gray-700 text-white px-6 py-3 rounded-lg cursor-pointer transition-colors w-full"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 fill-white" viewBox="0 0 32 32">
<path
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
/>
<path
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
/>
</svg>
<span class="text-sm font-medium">
{{ file() !== null ? "Changer l'image" : 'Sélectionner une image' }}
</span>
<input
type="file"
id="uploadFile1"
class="hidden"
accept="image/jpeg,image/png,image/webp"
(change)="fileManagerService.onPictureChange($event)"
/>
</label>
<!-- Information sur les formats acceptés -->
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
Formats acceptés : JPEG, PNG, WebP | Taille maximale : 5 Mo
</p>
<!-- Bouton de soumission -->
@if (file() !== null && !fileError()) {
<button
type="button"
(click)="onSubmit()"
[disabled]="!canSubmit"
class="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
Mettre à jour l'image du projet
</button>
}
</div>
<label
for="uploadFile1"
class="flex bg-gray-800 hover:bg-gray-700 text-white text-base px-5 py-3 outline-none rounded w-max cursor-pointer mx-auto font-[sans-serif]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 mr-2 fill-white inline"
viewBox="0 0 32 32"
>
<path
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
data-original="#000000"
/>
<path
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
data-original="#000000"
/>
</svg>
<small class="text-xs">Selectionner une image</small>
<input
type="file"
id="uploadFile1"
class="hidden"
accept="image/*"
(change)="onPictureChange($event)"
/>
</label>
@if (file !== null || imagePreviewUrl !== null) {
<button
type="button"
[ngClass]="{ 'bg-purple-600': file !== null || imagePreviewUrl !== null }"
class="!mt-2 px-6 py-2 w-full bg-[#333] hover:bg-[#444] text-sm text-white mx-auto block"
(click)="onSubmit()"
>
Mettre à jour ma photo de projet
</button>
}
</ng-template>

View File

@@ -1,36 +1,160 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectPictureFormComponent } from './project-picture-form.component';
import { provideRouter } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-repository.token';
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { mockToastR } from '@app/testing/toastr.mock';
import { mockProjectRepo } from '@app/testing/project.mock';
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 { ActionType } from '@app/domain/action-type.util';
describe('ProjectPictureFormComponent', () => {
let component: ProjectPictureFormComponent;
let fixture: ComponentFixture<ProjectPictureFormComponent>;
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>> = mockProjectRepo;
// 1. Mocks des services
const mockFileManagerService = mockFileManagerSvc;
const mockProjectFacade = mockProjectFac;
// Données de test
const mockProject: ProjectViewModel = mockProjects[0];
beforeEach(async () => {
// Reset des mocks
mockFileManagerService.file.set(null);
mockFileManagerService.imagePreviewUrl.set(null);
mockFileManagerService.fileError.set(null);
mockFileManagerService.resetFile.mockClear();
mockProjectFacade.update.mockClear();
mockProjectFacade.loading.set({ isLoading: false, action: ActionType.NONE });
mockProjectFacade.error.set({ hasError: false });
await TestBed.configureTestingModule({
imports: [ProjectPictureFormComponent],
providers: [
provideRouter([]),
{ provide: ToastrService, useValue: mockToastrService },
{ provide: PROJECT_REPOSITORY_TOKEN, useValue: mockProjectRepository },
{ provide: ProjectFacade, useValue: mockProjectFacade },
{ provide: FileManagerService, useValue: mockFileManagerService },
],
}).compileComponents();
fixture = TestBed.createComponent(ProjectPictureFormComponent);
component = fixture.componentInstance;
// Initialisation de l'input requis
fixture.componentRef.setInput('project', mockProject);
fixture.detectChanges();
});
it('should create', () => {
it('devrait créer le composant', () => {
expect(component).toBeTruthy();
});
describe("Affichage de l'image (currentImageUrl)", () => {
it('devrait prioriser la prévisualisation locale (upload en cours)', () => {
mockFileManagerService.imagePreviewUrl.set('data:image/png;base64,preview');
fixture.detectChanges();
expect(component.currentImageUrl).toBe('data:image/png;base64,preview');
});
it("devrait afficher l'image du serveur si pas de preview", () => {
// mockProject a 'image-serveur.jpg'
expect(component.currentImageUrl).toContain('portfolio-preview.png');
});
/*it('devrait générer une image Dicebear avec le nom du projet si aucune image', () => {
const projectNoImg = { ...mockProject, fichier: undefined };
fixture.componentRef.setInput('project', projectNoImg);
fixture.detectChanges();
expect(component.currentImageUrl).toContain('api.dicebear.com');
expect(component.currentImageUrl).toContain('Web 3D');
});*/
/*it("devrait générer une image par défaut si le projet n'a ni image ni nom", () => {
const emptyProject = { ...mockProject, fichier: undefined, nom: undefined };
fixture.componentRef.setInput('project', emptyProject);
fixture.detectChanges();
expect(component.currentImageUrl).toContain(undefined);
});*/
});
describe('Validation (canSubmit)', () => {
it('devrait être true si un fichier est prêt et valide', () => {
mockFileManagerService.file.set(new File([''], 'img.jpg'));
mockFileManagerService.fileError.set(null);
mockProjectFacade.loading.set({ isLoading: false, action: ActionType.NONE });
expect(component.canSubmit).toBe(true);
});
it('devrait être false si une erreur de fichier existe', () => {
mockFileManagerService.file.set(new File([''], 'img.jpg'));
mockFileManagerService.fileError.set('Trop lourd');
expect(component.canSubmit).toBe(false);
});
it('devrait être false si pas de fichier ni de preview', () => {
mockFileManagerService.file.set(null);
mockFileManagerService.imagePreviewUrl.set(null);
expect(component.canSubmit).toBe(false);
});
});
describe('Soumission (onSubmit)', () => {
it('devrait envoyer un FormData à la façade', () => {
const file = new File(['blob'], 'test.png');
mockFileManagerService.file.set(file);
component.onSubmit();
expect(mockProjectFacade.update).toHaveBeenCalledTimes(1);
const args = mockProjectFacade.update.mock.calls[0];
expect(args[0]).toBe('1'); // ID projet
expect(args[1]).toBeInstanceOf(FormData);
expect(args[1].get('fichier')).toBe(file);
});
it('ne devrait rien faire si pas de fichier', () => {
mockFileManagerService.file.set(null);
component.onSubmit();
expect(mockProjectFacade.update).not.toHaveBeenCalled();
});
});
describe('Effets (Réinitialisation après succès)', () => {
it("devrait émettre onFormSubmitted et reset le fichier quand l'update réussit", () => {
// 1. Espionner l'output
let emitted = false;
component.onFormSubmitted.subscribe(() => (emitted = true));
// 2. Déclencher la soumission
mockFileManagerService.file.set(new File([''], 'test.jpg'));
component.onSubmit(); // onSubmitted -> true
// 3. Simuler la fin du chargement succès
mockProjectFacade.loading.set({ isLoading: false, action: ActionType.UPDATE });
fixture.detectChanges(); // Déclenche l'effect
expect(emitted).toBe(true);
expect(mockFileManagerService.resetFile).toHaveBeenCalled();
});
it("ne devrait rien faire si l'action n'est pas UPDATE", () => {
component.onSubmit();
mockProjectFacade.loading.set({ isLoading: false, action: ActionType.CREATE });
fixture.detectChanges();
expect(mockFileManagerService.resetFile).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,99 +1,82 @@
import { Component, effect, inject, Input, output, signal } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { Component, effect, inject, input, output, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { environment } from '@env/environment';
import { ToastrService } from 'ngx-toastr';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { ProjectFacade } from '@app/ui/projects/project.facade';
import { ProjectViewModel } from '@app/adapters/projects/project.presenter.model';
import { ProjectFacade } from '@app/adapters/projects/project.facade';
import { ActionType } from '@app/domain/action-type.util';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { FileManagerService } from '@app/adapters/shared/services/file-manager.service';
@Component({
selector: 'app-project-picture-form',
standalone: true,
imports: [NgClass, LoadingComponent, NgTemplateOutlet],
imports: [LoadingComponent, NgTemplateOutlet],
templateUrl: './project-picture-form.component.html',
styleUrl: './project-picture-form.component.scss',
})
export class ProjectPictureFormComponent {
@Input({ required: true }) project: ProjectViewModel | undefined = undefined;
project = input.required<ProjectViewModel | undefined>();
protected readonly environment = environment;
protected readonly ActionType = ActionType;
protected readonly fileManagerService = inject(FileManagerService);
onFormSubmitted = output<any>();
private readonly toastrService = inject(ToastrService);
private readonly projectFacade = new ProjectFacade();
private readonly projectFacade = inject(ProjectFacade);
protected readonly loading = this.projectFacade.loading;
protected readonly error = this.projectFacade.error;
file: File | null = null; // Variable to store file
imagePreviewUrl: string | null = null; // URL for image preview
file = this.fileManagerService.file; // Variable to store file
imagePreviewUrl = this.fileManagerService.imagePreviewUrl; // URL for image preview
fileError = this.fileManagerService.fileError;
protected onSubmitted = signal<boolean>(false);
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading && this.onSubmitted()) {
switch (this.loading().action) {
case ActionType.UPDATE:
message = `L'aperçu du projet ${this.project!.nom} ont bien été modifier !`;
this.customToast(ActionType.UPDATE, message);
this.onSubmitted.set(false);
this.onFormSubmitted.emit('');
break;
effect(
() => {
if (!this.loading().isLoading && this.onSubmitted()) {
switch (this.loading().action) {
case ActionType.UPDATE:
this.onSubmitted.set(false);
this.onFormSubmitted.emit('');
this.fileManagerService.resetFile();
break;
}
}
}
});
},
{ allowSignalWrites: true }
);
}
onSubmit() {
if (this.file != null) {
const file = this.file();
if (file && !this.fileError()) {
const formData = new FormData();
formData.append('fichier', this.file); // "fichier" est le nom du champ dans PocketBase
formData.append('fichier', file); // "fichier" est le nom du champ dans PocketBase
this.projectFacade.update(this.project?.id!, formData);
this.projectFacade.update(this.project()?.id!, formData);
this.onSubmitted.set(true);
}
}
onPictureChange($event: Event) {
const target: HTMLInputElement = $event.target as HTMLInputElement;
if (target?.files?.[0]) {
this.file = target.files[0];
this.readFile(this.file);
}
}
private readFile(file: File) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewUrl = e.target?.result as string;
};
reader.readAsDataURL(file);
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
this.toastrService.success(
`${message}`,
`${action === ActionType.UPDATE ? 'Mise à jour' : ''}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
get canSubmit(): boolean {
return (
(this.file() != null || this.imagePreviewUrl() != null) &&
!this.fileError() &&
!this.error().hasError &&
!this.loading().isLoading
);
}
get currentImageUrl(): string | null {
if (this.imagePreviewUrl()) {
return this.imagePreviewUrl();
}
if (this.project()?.fichier) {
return this.project()!.fichier;
}
return 'https://api.dicebear.com/9.x/shapes/svg?seed=nouveau-projet';
}
}

View File

@@ -1,77 +1,130 @@
@if (loading().action === ActionType.UPDATE && loading().isLoading && onSubmitted()) {
<app-loading message="Mise à jour de la photo de profile..." />
<app-loading message="Mise à jour de la photo de profil..." />
} @else {
<ng-container *ngTemplateOutlet="content" />
}
<ng-template #content>
@if (user !== undefined) {
<div
class="w-40 h-40 rounded-full inline-flex items-center justify-center bg-gray-200 text-gray-400"
>
@if (imagePreviewUrl !== null) {
<img
alt="{{ user!.username }}"
class="object-cover object-center h-full w-full rounded-full"
[src]="imagePreviewUrl"
loading="lazy"
/>
} @else if (user.avatar) {
<img
alt="{{ user!.username }}"
class="object-cover object-center h-full w-full rounded-full"
src="{{ environment.baseUrl }}/api/files/users/{{ user.id }}/{{ user.avatar }}"
loading="lazy"
/>
} @else {
<img
alt="{{ user!.username }}"
class="object-cover object-center h-full w-full rounded-full"
src="https://api.dicebear.com/9.x/initials/svg?seed={{
user.name ? user.name : user.username ? user.username : user.email
}}"
loading="lazy"
/>
<div class="space-y-6 max-w-md mx-auto">
<!-- Titre -->
<h3 class="font-ubuntu font-bold text-xl uppercase dark:text-white text-center">
Photo de profil
</h3>
<!-- Avatar avec preview -->
<div class="flex flex-col items-center space-y-4">
<div class="relative">
@if (user(); as user) {
<div
class="w-40 h-40 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex items-center justify-center shadow-lg ring-4 ring-gray-100 dark:ring-gray-600"
>
<img
[alt]="user.username || 'Avatar'"
class="object-cover object-center h-full w-full"
[src]="currentAvatarUrl"
loading="lazy"
/>
</div>
}
<!-- Bouton de suppression si preview existe -->
@if (imagePreviewUrl !== null) {
<button
type="button"
(click)="fileManagerService.removeFile()"
class="absolute -top-2 -right-2 p-2 bg-red-600 hover:bg-red-700 rounded-full text-white transition-colors shadow-lg"
aria-label="Supprimer l'image"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
}
</div>
<!-- Informations sur le fichier sélectionné -->
@if (file(); as file) {
<div class="text-center space-y-1">
<p class="text-sm text-gray-700 dark:text-gray-300 font-medium break-all max-w-[250px]">
{{ file.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ fileManagerService.formatFileSize(file.size) }}
</p>
</div>
}
</div>
}
<label
for="uploadFile1"
class="flex bg-gray-800 hover:bg-gray-700 text-white text-base px-5 py-3 outline-none rounded w-max cursor-pointer mx-auto font-[sans-serif]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 mr-2 fill-white inline"
viewBox="0 0 32 32"
>
<path
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
data-original="#000000"
/>
<path
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
data-original="#000000"
/>
</svg>
<small class="text-xs">Selectionner une image</small>
<input
type="file"
id="uploadFile1"
class="hidden"
accept="image/*"
(change)="onPictureChange($event)"
/>
</label>
<!-- Message d'erreur -->
@if (fileError()) {
<div
class="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
clip-rule="evenodd"
/>
</svg>
<p class="text-sm text-red-800 dark:text-red-200">{{ fileError() }}</p>
</div>
}
@if (file !== null || imagePreviewUrl !== null) {
<button
type="button"
[ngClass]="{ 'bg-purple-600': file !== null || imagePreviewUrl !== null }"
class="!mt-2 px-6 py-2 w-full bg-[#333] hover:bg-[#444] text-sm text-white mx-auto block"
(click)="onUserAvatarFormSubmit()"
<!-- Bouton de sélection -->
<label
for="uploadFile1"
class="flex flex-col sm:flex-row items-center justify-center space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-800 hover:bg-gray-700 text-white px-6 py-3 rounded-lg cursor-pointer transition-colors w-full"
>
Mettre à jour ma photo de profile
</button>
}
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 fill-white" viewBox="0 0 32 32">
<path
d="M23.75 11.044a7.99 7.99 0 0 0-15.5-.009A8 8 0 0 0 9 27h3a1 1 0 0 0 0-2H9a6 6 0 0 1-.035-12 1.038 1.038 0 0 0 1.1-.854 5.991 5.991 0 0 1 11.862 0A1.08 1.08 0 0 0 23 13a6 6 0 0 1 0 12h-3a1 1 0 0 0 0 2h3a8 8 0 0 0 .75-15.956z"
/>
<path
d="M20.293 19.707a1 1 0 0 0 1.414-1.414l-5-5a1 1 0 0 0-1.414 0l-5 5a1 1 0 0 0 1.414 1.414L15 16.414V29a1 1 0 0 0 2 0V16.414z"
/>
</svg>
<span class="text-sm font-medium">
{{ file() !== null ? "Changer l'image" : 'Sélectionner une image' }}
</span>
<input
type="file"
id="uploadFile1"
class="hidden"
accept="image/jpeg,image/png,image/webp"
(change)="fileManagerService.onPictureChange($event)"
/>
</label>
<!-- Information sur les formats acceptés -->
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
Formats acceptés : JPEG, PNG, WebP | Taille maximale : 5 Mo
</p>
<!-- Bouton de soumission -->
@if (file() !== null && !fileError()) {
<button
type="button"
(click)="onUserAvatarFormSubmit()"
[disabled]="!canSubmit"
class="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
Mettre à jour ma photo de profil
</button>
}
</div>
</ng-template>

View File

@@ -1,36 +1,162 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserAvatarFormComponent } from './user-avatar-form.component';
import { provideRouter } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { UserRepository } from '@app/domain/users/user.repository';
import { USER_REPOSITORY_TOKEN } from '@app/infrastructure/users/user-repository.token';
import { mockToastR } from '@app/testing/toastr.mock';
import { mockUserRepo } from '@app/testing/user.mock';
import { UserViewModel } from '../../../adapters/users/user.presenter.model';
import { FileManagerService } from '../../../adapters/shared/services/file-manager.service';
import { UserFacade } from '../../../adapters/users/user.facade';
import { ActionType } from '@app/domain/action-type.util';
import { mockUserRepo, mockUsers } from '@app/testing/user.mock';
import { USER_REPOSITORY_TOKEN } from '@app/infrastructure/users/user-repository.token';
import { provideRouter } from '@angular/router';
import { mockFileManagerSvc } from '@app/testing/file-manager.service.mock';
import { mockUserFac } from '@app/testing/adapters/users/user.facade.mock';
describe('UserAvatarFormComponent', () => {
let component: UserAvatarFormComponent;
let fixture: ComponentFixture<UserAvatarFormComponent>;
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
let mockUserRepository: jest.Mocked<Partial<UserRepository>> = mockUserRepo;
// 1. Définition des Mocks
let mockFileManagerService = mockFileManagerSvc;
let mockUserFacade = mockUserFac;
// Données de test
const mockUser: UserViewModel = mockUsers[0];
beforeEach(async () => {
// Réinitialisation des mocks avant chaque test
mockFileManagerService.file.set(null);
mockFileManagerService.imagePreviewUrl.set(null);
mockFileManagerService.fileError.set(null);
mockFileManagerService.resetFile.mockClear();
mockUserFacade.update.mockClear();
mockUserFacade.loading.set({ isLoading: false, action: ActionType.NONE });
await TestBed.configureTestingModule({
imports: [UserAvatarFormComponent],
providers: [
provideRouter([]),
{ provide: USER_REPOSITORY_TOKEN, useValue: mockUserRepository },
{ provide: ToastrService, useValue: mockToastrService },
{ provide: USER_REPOSITORY_TOKEN, useValue: mockUserRepo },
{ provide: UserFacade, useValue: mockUserFacade },
{ provide: FileManagerService, useValue: mockFileManagerService },
{ provide: ToastrService, useValue: mockToastR },
],
}).compileComponents();
fixture = TestBed.createComponent(UserAvatarFormComponent);
component = fixture.componentInstance;
// Initialisation de l'input requis (Signal Input)
fixture.componentRef.setInput('user', mockUser);
fixture.detectChanges();
});
it('should create', () => {
it('devrait créer le composant', () => {
expect(component).toBeTruthy();
});
describe("Affichage de l'avatar (currentAvatarUrl)", () => {
it('devrait prioriser la prévisualisation locale si elle existe', () => {
mockFileManagerService.imagePreviewUrl.set('data:image/png;base64,preview');
// On force la détection pour mettre à jour la vue
fixture.detectChanges();
expect(component.currentAvatarUrl).toBe('data:image/png;base64,preview');
});
it("devrait afficher un avatar Dicebear si l'utilisateur n'a pas d'avatar", () => {
const userNoAvatar = { ...mockUser, avatar: null };
fixture.componentRef.setInput('user', userNoAvatar);
fixture.detectChanges();
expect(component.currentAvatarUrl).toContain('api.dicebear.com');
// Vérifie que le seed utilise le nom
expect(component.currentAvatarUrl).toContain(
'https://api.dicebear.com/9.x/initials/svg?seed=foo'
);
});
});
describe('Validation du formulaire (canSubmit)', () => {
it('devrait être false par défaut', () => {
expect(component.canSubmit).toBe(false);
});
it('devrait être true si un fichier est présent et valide', () => {
mockFileManagerService.file.set(new File([''], 'test.png'));
mockFileManagerService.fileError.set(null);
mockUserFacade.loading.set({ isLoading: false, action: ActionType.NONE });
expect(component.canSubmit).toBe(true);
});
it('devrait être false si une erreur de fichier est présente', () => {
mockFileManagerService.file.set(new File([''], 'test.png'));
mockFileManagerService.fileError.set('Fichier trop lourd');
expect(component.canSubmit).toBe(false);
});
it('devrait être false si un chargement est en cours', () => {
mockFileManagerService.file.set(new File([''], 'test.png'));
mockUserFacade.loading.set({ isLoading: true, action: ActionType.UPDATE });
expect(component.canSubmit).toBe(false);
});
});
describe('Soumission (onUserAvatarFormSubmit)', () => {
it('devrait appeler la façade avec FormData si tout est valide', () => {
const validFile = new File(['content'], 'avatar.jpg');
mockFileManagerService.file.set(validFile);
component.onUserAvatarFormSubmit();
expect(mockUserFacade.update).toHaveBeenCalledTimes(1);
// Vérification des arguments passés à update
const args = mockUserFacade.update.mock.calls[0];
expect(args[0]).toBe('user_001'); // ID User
expect(args[1]).toBeInstanceOf(FormData); // Le payload doit être un FormData
expect(args[1].get('avatar')).toBe(validFile); // Le fichier doit être dedans
});
it("ne devrait RIEN faire si aucun fichier n'est sélectionné", () => {
mockFileManagerService.file.set(null);
component.onUserAvatarFormSubmit();
expect(mockUserFacade.update).not.toHaveBeenCalled();
});
});
describe('Gestion des effets (Effect)', () => {
it('devrait réinitialiser le fichier après une mise à jour réussie', () => {
// 1. On prépare l'état initial
mockFileManagerService.file.set(new File([''], 'test.jpg'));
// 2. On déclenche la soumission pour passer le signal 'onSubmitted' à true
component.onUserAvatarFormSubmit();
// 3. On simule la FIN du chargement (isLoading passe de true à false)
// L'effet écoute: !loading().isLoading && action === UPDATE && onSubmitted()
mockUserFacade.loading.set({ isLoading: false, action: ActionType.UPDATE });
// Force la détection des changements pour déclencher l'effect Angular
fixture.detectChanges();
expect(mockFileManagerService.resetFile).toHaveBeenCalled();
});
it("ne devrait PAS réinitialiser le fichier si l'action n'est pas UPDATE", () => {
component.onUserAvatarFormSubmit();
// Simulation d'une autre action (ex: READ)
mockUserFacade.loading.set({ isLoading: false, action: ActionType.READ });
fixture.detectChanges();
expect(mockFileManagerService.resetFile).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,48 +1,47 @@
import { Component, effect, inject, Input, signal } from '@angular/core';
import { Component, effect, inject, input, signal } from '@angular/core';
import { User } from '@app/domain/users/user.model';
import { ReactiveFormsModule } from '@angular/forms';
import { environment } from '@env/environment';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { ToastrService } from 'ngx-toastr';
import { UserFacade } from '@app/ui/users/user.facade';
import { NgTemplateOutlet } from '@angular/common';
import { UserFacade } from '@app/adapters/users/user.facade';
import { ActionType } from '@app/domain/action-type.util';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { UntilDestroy } from '@ngneat/until-destroy';
import { UserViewModel } from '@app/ui/users/user.presenter.model';
import { UserViewModel } from '@app/adapters/users/user.presenter.model';
import { FileManagerService } from '@app/adapters/shared/services/file-manager.service';
@Component({
selector: 'app-user-avatar-form',
standalone: true,
providers: [UserFacade],
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, LoadingComponent],
imports: [ReactiveFormsModule, NgTemplateOutlet, LoadingComponent],
templateUrl: './user-avatar-form.component.html',
styleUrl: './user-avatar-form.component.scss',
})
@UntilDestroy()
export class UserAvatarFormComponent {
private readonly toastrService = inject(ToastrService);
protected readonly environment = environment;
protected readonly fileManagerService = inject(FileManagerService);
private readonly facade = inject(UserFacade);
protected readonly ActionType = ActionType;
@Input({ required: true }) user: UserViewModel | undefined = undefined;
user = input.required<UserViewModel | undefined>();
file = this.fileManagerService.file; // Variable to store file
imagePreviewUrl = this.fileManagerService.imagePreviewUrl; // URL for image preview
fileError = this.fileManagerService.fileError;
file: File | null = null; // Variable to store file
imagePreviewUrl: string | null = null; // URL for image preview
protected onSubmitted = signal<boolean>(false);
protected readonly loading = this.facade.loading;
protected readonly error = this.facade.error;
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.UPDATE:
message = `Votre photo de profile a bien été modifier !`;
if (this.onSubmitted()) {
this.customToast(ActionType.UPDATE, message);
this.fileManagerService.resetFile();
}
break;
}
@@ -50,56 +49,40 @@ export class UserAvatarFormComponent {
});
}
onUserAvatarFormSubmit() {
if (this.file != null) {
const formData = new FormData();
formData.append('avatar', this.file); // "avatar" est le nom du champ dans PocketBase
onUserAvatarFormSubmit(): void {
const file = this.file();
const user = this.user();
this.facade.update(this.user!.id, formData as Partial<User>);
if (file && !this.fileError() && user?.id) {
const formData = new FormData();
formData.append('avatar', file);
this.facade.update(user.id, formData as unknown as Partial<User>);
this.onSubmitted.set(true);
}
}
onPictureChange($event: Event) {
const target: HTMLInputElement = $event.target as HTMLInputElement;
if (target?.files?.[0]) {
this.file = target.files[0];
this.readFile(this.file);
}
}
private readFile(file: File) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewUrl = e.target?.result as string;
};
reader.readAsDataURL(file);
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
this.toastrService.success(
`${message}`,
`${action === ActionType.UPDATE ? 'Mise à jour' : ''}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
get canSubmit(): boolean {
return (
(this.file() != null || this.imagePreviewUrl() != null) &&
!this.fileError() &&
!this.error().hasError &&
!this.loading().isLoading
);
}
protected readonly ActionType = ActionType;
get currentAvatarUrl(): string | null {
const currentUser = this.user();
if (this.imagePreviewUrl()) {
return this.imagePreviewUrl();
}
if (currentUser?.avatar) {
return currentUser.avatar;
}
return `https://api.dicebear.com/9.x/initials/svg?seed=${currentUser!.name ?? 'trouveTonProfil'}`;
}
}

View File

@@ -9,7 +9,7 @@ import {
import { User } from '@app/domain/users/user.model';
import { UntilDestroy } from '@ngneat/until-destroy';
import { ToastrService } from 'ngx-toastr';
import { UserFacade } from '@app/ui/users/user.facade';
import { UserFacade } from '@app/adapters/users/user.facade';
import { ActionType } from '@app/domain/action-type.util';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { NgClass, NgTemplateOutlet } from '@angular/common';

View File

@@ -2,12 +2,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserPasswordFormComponent } from './user-password-form.component';
import { ToastrService } from 'ngx-toastr';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { AuthFacade } from '../../../adapters/authentification/auth.facade';
import { By } from '@angular/platform-browser';
import { ActionType } from '@app/domain/action-type.util';
import { signal, WritableSignal } from '@angular/core';
import { mockToastR } from '@app/testing/toastr.mock';
import { mockAuthenticationFacade } from '@app/testing/ui/authentification/auth.facade.mock';
import { mockAuthenticationFacade } from '@app/testing/adapters/authentification/auth.facade.mock';
describe('UserPasswordFormComponent', () => {
let component: UserPasswordFormComponent;

View File

@@ -1,7 +1,7 @@
import { Component, effect, inject, Input, output } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { UserViewModel } from '@app/ui/users/user.presenter.model';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { UserViewModel } from '@app/adapters/users/user.presenter.model';
import { AuthFacade } from '@app/adapters/authentification/auth.facade';
import { ActionType } from '@app/domain/action-type.util';
import { ToastrService } from 'ngx-toastr';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';

View File

@@ -1,11 +1,15 @@
@if (user() !== undefined) {
<a [routerLink]="[slug()]" [state]="{ user: user(), profile }" class="block group">
@if (user(); as user) {
<a
[routerLink]="[profile()!.slug!]"
[state]="{ user: user, profile: profile() }"
class="block group"
>
<!-- Card du profil -->
<div
class="relative bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-2"
>
<!-- Badge vérifié (position absolue en haut à droite) -->
@if (profile.estVerifier) {
@if (profile().estVerifier) {
<div class="absolute top-3 right-3 z-10 animate-pulse-slow">
<div class="bg-purple-500/20 backdrop-blur-md p-2 rounded-full">
<svg
@@ -29,44 +33,33 @@
<!-- Avatar avec bordure gradient -->
<div class="relative inline-block mb-4">
<div class="w-32 h-32 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 p-1">
@if (user().avatar) {
<img
class="w-full h-full rounded-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500 group-hover:scale-105"
src="{{ environment.baseUrl }}/api/files/users/{{ user().id }}/{{ user().avatar }}"
alt="{{ user().username }}"
loading="lazy"
/>
} @else {
<img
class="w-full h-full rounded-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500 group-hover:scale-105"
src="https://api.dicebear.com/9.x/initials/svg?seed={{ user().name }}"
alt="{{ user().username }}"
loading="lazy"
/>
}
<img
class="w-full h-full rounded-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500 group-hover:scale-105"
src="{{ profile().avatarUrl }}"
alt="{{ user.username }}"
loading="lazy"
/>
</div>
</div>
<!-- Nom -->
@if (user().name) {
@if (user.name) {
<h3
class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
>
{{ user().firstName }} {{ user().lastName }}
{{ user.firstName }} {{ user.lastName }}
</h3>
} @else if (user().username) {
} @else if (user.username) {
<h3
class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
>
{{ user().username }}
{{ user.username }}
</h3>
} @else {
<h3 class="text-lg font-bold text-gray-500 dark:text-gray-400 mb-2">Non mentionné</h3>
}
<!-- Profession -->
<p class="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
{{ profile.profession || 'Profession non renseignée' }}
{{ profile().profession }}
</p>
</div>

View File

@@ -4,13 +4,13 @@ import { VerticalProfileItemComponent } from './vertical-profile-item.component'
import { UserRepository } from '@app/domain/users/user.repository';
import { provideRouter } from '@angular/router';
import { USER_REPOSITORY_TOKEN } from '@app/infrastructure/users/user-repository.token';
import { User } from '@app/domain/users/user.model';
import { of } from 'rxjs';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { SectorRepository } from '@app/domain/sectors/sector.repository';
import { Sector } from '@app/domain/sectors/sector.model';
import { mockUserRepo } from '@app/testing/user.mock';
import { mockSectorRepo } from '@app/testing/sector.mock';
import { ProfileViewModel } from '../../../adapters/profiles/profile.presenter.model';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('VerticalProfileItemComponent', () => {
let component: VerticalProfileItemComponent;
@@ -19,6 +19,11 @@ describe('VerticalProfileItemComponent', () => {
let mockUserRepository: jest.Mocked<Partial<UserRepository>> = mockUserRepo;
let mockSectorRepository: jest.Mocked<Partial<SectorRepository>> = mockSectorRepo;
const mockProfile: Partial<ProfileViewModel> = {
id: 'fakeId',
utilisateur: 'fakeUtilisateur',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VerticalProfileItemComponent],
@@ -26,11 +31,15 @@ describe('VerticalProfileItemComponent', () => {
provideRouter([]),
{ provide: USER_REPOSITORY_TOKEN, useValue: mockUserRepository },
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepository },
{ provide: ToastrService, useValue: mockToastR },
],
}).compileComponents();
fixture = TestBed.createComponent(VerticalProfileItemComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('profile', mockProfile);
fixture.detectChanges();
});

View File

@@ -1,9 +1,9 @@
import { Component, computed, inject, Input, OnInit } from '@angular/core';
import { Component, computed, inject, input, OnInit } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { environment } from '@env/environment';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { UserFacade } from '@app/ui/users/user.facade';
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
import { UserFacade } from '@app/adapters/users/user.facade';
@Component({
selector: 'app-vertical-profile-item',
@@ -15,9 +15,10 @@ import { UserFacade } from '@app/ui/users/user.facade';
})
@UntilDestroy()
export class VerticalProfileItemComponent implements OnInit {
@Input({ required: true }) profile: ProfileViewModel = {} as ProfileViewModel;
protected router = inject(Router);
profile = input.required<ProfileViewModel>();
protected readonly router = inject(Router);
private readonly facade = inject(UserFacade);
protected readonly environment = environment;
protected user = this.facade.user;
protected readonly loading = this.facade.loading;
@@ -25,13 +26,13 @@ export class VerticalProfileItemComponent implements OnInit {
protected slug = computed(() => {
const slug = this.user().slug ?? '';
const profileId = this.profile.id ? this.profile.id : '';
const profileId = this.profile().id ? this.profile().id : '';
return slug === '' ? profileId : slug.concat('-', profileId);
});
ngOnInit(): void {
this.facade.loadOne(this.profile.utilisateur);
if (this.profile()) {
this.facade.loadOne(this.profile().utilisateur);
}
}
protected readonly environment = environment;
}

View File

@@ -5,38 +5,40 @@
@for (profile of profiles; track profile.id) {
<app-vertical-profile-item [profile]="profile" />
} @empty {
<!-- Message si aucun profil -->
<div class="col-span-full">
<div
class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-xl p-12 text-center"
>
<div
class="inline-flex w-20 h-20 bg-white dark:bg-gray-600 rounded-full items-center justify-center mb-6 shadow-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-10 h-10 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
Aucun profil trouvé
</h3>
<p class="text-gray-600 dark:text-gray-300 max-w-md mx-auto">
Aucun profil ne correspond à votre recherche. Essayez de modifier vos critères.
</p>
</div>
</div>
<ng-container *ngTemplateOutlet="emptyMessage" />
}
</div>
</div>
</section>
<ng-template #emptyMessage>
<!-- Message si aucun profil -->
<div class="col-span-full">
<div
class="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-xl p-12 text-center"
>
<div
class="inline-flex w-20 h-20 bg-white dark:bg-gray-600 rounded-full items-center justify-center mb-6 shadow-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-10 h-10 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Aucun profil trouvé</h3>
<p class="text-gray-600 dark:text-gray-300 max-w-md mx-auto">
Aucun profil ne correspond à votre recherche. Essayez de modifier vos critères.
</p>
</div>
</div>
</ng-template>

View File

@@ -1,11 +1,12 @@
import { Component, Input } from '@angular/core';
import { VerticalProfileItemComponent } from '@app/shared/components/vertical-profile-item/vertical-profile-item.component';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
import { NgTemplateOutlet } from '@angular/common';
@Component({
selector: 'app-vertical-profile-list',
standalone: true,
imports: [VerticalProfileItemComponent],
imports: [VerticalProfileItemComponent, NgTemplateOutlet],
templateUrl: './vertical-profile-list.component.html',
styleUrl: './vertical-profile-list.component.scss',
})

View File

@@ -5,12 +5,11 @@ import { SectorRepository } from '@app/domain/sectors/sector.repository';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { provideRouter } from '@angular/router';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { of } from 'rxjs';
import { Profile } from '@app/domain/profiles/profile.model';
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { Sector } from '@app/domain/sectors/sector.model';
import { mockProfileRepo } from '@app/testing/profile.mock';
import { mockSectorRepo } from '@app/testing/sector.mock';
import { ToastrService } from 'ngx-toastr';
import { mockToastR } from '@app/testing/toastr.mock';
describe('FilterComponent', () => {
let component: FilterComponent;
@@ -25,6 +24,7 @@ describe('FilterComponent', () => {
provideRouter([]),
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepository },
{ provide: ToastrService, useValue: mockToastR },
],
}).compileComponents();

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnInit, output } from '@angular/core';
import { SectorFacade } from '@app/ui/sectors/sector.facade';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { SectorFacade } from '@app/adapters/sectors/sector.facade';
import { ProfileFacade } from '@app/adapters/profiles/profile.facade';
import { SearchService } from '@app/infrastructure/search/search.service';
import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { SearchFilters } from '@app/domain/search/search-filters';

View File

@@ -4,10 +4,11 @@ import { ForgotPasswordComponent } from './forgot-password.component';
import { ToastrService } from 'ngx-toastr';
import { signal, WritableSignal } from '@angular/core';
import { ActionType } from '@app/domain/action-type.util';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { AuthFacade } from '../../../adapters/authentification/auth.facade';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { Subject } from 'rxjs';
// TODO : Model mock de la Facade
describe('ForgotPasswordComponent', () => {
let component: ForgotPasswordComponent;
let fixture: ComponentFixture<ForgotPasswordComponent>;
@@ -96,11 +97,11 @@ describe('ForgotPasswordComponent', () => {
fixture.detectChanges();
expect(mockToastrService.success).toHaveBeenCalledWith(
/*expect(mockToastrService.success).toHaveBeenCalledWith(
expect.stringContaining('success@test.com'),
expect.anything(),
expect.anything()
);
);*/
expect(mockRouter.navigate).toHaveBeenCalledWith(['/auth']);
});
@@ -112,7 +113,7 @@ describe('ForgotPasswordComponent', () => {
fixture.detectChanges();
expect(mockToastrService.error).toHaveBeenCalled();
//expect(mockToastrService.error).toHaveBeenCalled();
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(mockToastrService.success).not.toHaveBeenCalled();
});

View File

@@ -2,8 +2,7 @@ import { Component, effect, inject } from '@angular/core';
import { BtnLoadingComponent } from '@app/shared/components/btn-loading/btn-loading.component';
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { ToastrService } from 'ngx-toastr';
import { AuthFacade } from '@app/adapters/authentification/auth.facade';
import { ActionType } from '@app/domain/action-type.util';
@Component({
@@ -16,7 +15,6 @@ import { ActionType } from '@app/domain/action-type.util';
export class ForgotPasswordComponent {
private readonly fb = inject(FormBuilder);
private readonly facade = inject(AuthFacade);
private readonly toastrService = inject(ToastrService);
protected readonly router = inject(Router);
fpForm = this.fb.group({
@@ -33,8 +31,6 @@ export class ForgotPasswordComponent {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.CREATE:
message = `Un mail de réinitialisation vous a été envoyé à cette adresse mail : ${this.fpForm.getRawValue().email!}`;
this.customToast(ActionType.CREATE, message);
if (!this.error().hasError && this.facade.isRequestPasswordSent()) {
this.router.navigate(['/auth']);
}
@@ -51,30 +47,4 @@ export class ForgotPasswordComponent {
}
this.facade.sendRequestPasswordReset(this.fpForm.getRawValue().email!);
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
this.toastrService.success(
`${message}`,
`${action === ActionType.CREATE ? 'Mise à jour' : ''}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
disableTimeOut: true,
}
);
}
}

View File

@@ -3,9 +3,8 @@ import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { LoginDto } from '@app/domain/authentification/dto/login-dto';
import { UntilDestroy } from '@ngneat/until-destroy';
import { ToastrService } from 'ngx-toastr';
import { ProgressBarModule } from 'primeng/progressbar';
import { AuthFacade } from '@app/ui/authentification/auth.facade';
import { AuthFacade } from '@app/adapters/authentification/auth.facade';
import { ActionType } from '@app/domain/action-type.util';
import { BtnLoadingComponent } from '@app/shared/components/btn-loading/btn-loading.component';
@@ -19,7 +18,6 @@ import { BtnLoadingComponent } from '@app/shared/components/btn-loading/btn-load
})
@UntilDestroy()
export class LoginComponent {
private readonly toastrService = inject(ToastrService);
private readonly facade = inject(AuthFacade);
private formBuilder = inject(FormBuilder);
@@ -37,29 +35,19 @@ export class LoginComponent {
protected readonly error = this.facade.error;
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading) {
switch (this.loading().action) {
case ActionType.READ:
if (!this.error().hasError) {
if (!this.authResponse()!.isValid && !this.authResponse()?.record.verified) {
message = `Vous ne pouvez pas vous connecter sans valider la verification envoyé à cet adresse ${this.authResponse()?.record.email!}`;
this.toastrService.warning(`${message}`, `CONNEXION`, {
closeButton: true,
progressBar: true,
disableTimeOut: true,
});
return;
}
this.router
.navigate(['/my-profile'], { state: { user: this.authResponse()!.record } })
.then(() => {
message = `Bienvenue parmi nous!`;
this.customToast(ActionType.READ, message);
});
if (this.error().hasError) {
this.loginForm.enable();
this.loginForm.markAllAsTouched();
this.loginForm.markAsDirty();
return;
}
this.router
.navigate(['/my-profile'], { state: { user: this.authResponse()!.record } })
.then(() => {});
break;
}
}
@@ -68,6 +56,8 @@ export class LoginComponent {
onSubmit() {
if (this.loginForm.invalid) {
this.loginForm.markAllAsTouched();
this.loginForm.markAsDirty();
return;
}
this.loginForm.disable();
@@ -78,35 +68,4 @@ export class LoginComponent {
this.facade.login(data);
}
private customToast(action: ActionType, message: string): void {
if (this.error().hasError) {
switch (this.error().action) {
case ActionType.READ:
this.toastrService.warning(`L'email ou mot de passe est incorrect`, `Erreur`, {
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
});
return;
default:
this.toastrService.error(
`Une erreur s'est produite, veuillez réessayer ulterieurement`,
`Erreur`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
);
return;
}
}
this.toastrService.success(`${message}`, `${action === ActionType.READ ? 'CONNEXION' : ''}`, {
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
});
}
}

View File

@@ -1,11 +1,10 @@
import { Component, effect, inject, input, OnInit, signal } from '@angular/core';
import { MapComponent, MapMarker } from '@app/shared/components/map/map.component';
import { Coordinates, CoordinatesValidator } from '@app/domain/localisation/coordinates.model';
import { LocationFacade } from '@app/ui/location/location.facade';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { LocationFacade } from '@app/adapters/location/location.facade';
import { ProfileFacade } from '@app/adapters/profiles/profile.facade';
import { ProfileViewModel } from '@app/adapters/profiles/profile.presenter.model';
import { ActionType } from '@app/domain/action-type.util';
import { FeedbackService } from '@app/ui/shared/services/feedback.service';
@Component({
selector: 'app-my-profile-map',
@@ -19,7 +18,6 @@ export class MyProfileMapComponent implements OnInit {
readonly profile = input<ProfileViewModel>();
private readonly locationFacade = inject(LocationFacade);
private readonly profileFacade = inject(ProfileFacade);
private readonly feedbackService = inject(FeedbackService);
private readonly loading = this.profileFacade.loading;
private readonly error = this.profileFacade.error;
@@ -53,11 +51,6 @@ export class MyProfileMapComponent implements OnInit {
switch (this.loading().action) {
case ActionType.UPDATE:
if (!this.error().hasError) {
this.feedbackService.notify(
ActionType.UPDATE,
'Vos coordonnées géographique ont été enregistrés.',
false
);
}
break;
}

Some files were not shown because too many files have changed in this diff Show More