refacto : gestion du poids des fichiers a upload

This commit is contained in:
styve Lioumba
2025-12-21 11:53:04 +01:00
parent dfda229a03
commit b7007c7ce3
13 changed files with 1510 additions and 320 deletions

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 != null) {
<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)="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">
{{ 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)="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

@@ -11,11 +11,13 @@ import { mockToastR } from '@app/testing/toastr.mock';
describe('MyProfileUpdateCvFormComponent', () => {
let component: MyProfileUpdateCvFormComponent;
let fixture: ComponentFixture<MyProfileUpdateCvFormComponent>;
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>> = mockProfileRepo;
let mockToastrService: jest.Mocked<Partial<ToastrService>>;
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>>;
beforeEach(async () => {
mockToastrService = mockToastR;
mockProjectRepository = mockProfileRepo;
await TestBed.configureTestingModule({
imports: [MyProfileUpdateCvFormComponent],
providers: [
@@ -28,12 +30,155 @@ describe('MyProfileUpdateCvFormComponent', () => {
fixture = TestBed.createComponent(MyProfileUpdateCvFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
// Injecter un profil requis pour le input()
fixture.componentRef.setInput('profile', { id: 'test-id' });
fixture.detectChanges();
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('File validation', () => {
it('should accept valid PDF file under 5MB', () => {
const validFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 }); // 1MB
const event = {
target: {
files: [validFile],
},
} as any;
component.onFileChange(event);
expect(component.file).toBe(validFile);
expect(component.fileError).toBeNull();
});
it('should reject file larger than 5MB', () => {
const largeFile = new File(['test content'], 'large.pdf', { type: 'application/pdf' });
Object.defineProperty(largeFile, 'size', { value: 6 * 1024 * 1024 }); // 6MB
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [largeFile], writable: true });
component.onFileChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toContain('trop volumineux');
expect(component.fileError).toContain('5 Mo');
});
it('should reject non-PDF files', () => {
const invalidFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [invalidFile], writable: true });
component.onFileChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toBe('Le fichier doit être au format PDF');
});
it('should handle no file selected', () => {
const event = {
target: {
files: [],
},
} as any;
component.onFileChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toBeNull();
});
});
describe('File removal', () => {
it('should remove file and reset error', () => {
component.file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
component.fileError = 'Some error';
component.removeFile();
expect(component.file).toBeNull();
expect(component.fileError).toBeNull();
});
});
describe('File size formatting', () => {
it('should format bytes correctly', () => {
expect(component.formatFileSize(0)).toBe('0 Octets');
expect(component.formatFileSize(1024)).toBe('1 Ko');
expect(component.formatFileSize(1024 * 1024)).toBe('1 Mo');
expect(component.formatFileSize(1536 * 1024)).toBe('1.5 Mo');
});
});
describe('Form submission', () => {
it('should not submit if no file is selected', () => {
const spy = jest.spyOn(component['profileFacade'], 'update');
component.file = null;
component.onSubmit();
expect(spy).not.toHaveBeenCalled();
});
it('should not submit if there is a file error', () => {
const spy = jest.spyOn(component['profileFacade'], 'update');
component.file = new File(['test'], 'test.pdf', { type: 'application/pdf' });
component.fileError = 'File too large';
component.onSubmit();
expect(spy).not.toHaveBeenCalled();
});
it('should submit valid file with FormData', () => {
const spy = jest.spyOn(component['profileFacade'], 'update');
const validFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
component.file = validFile;
component.fileError = null;
component.onSubmit();
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0];
expect(callArgs[0]).toBe('test-id');
expect(callArgs[1]).toBeInstanceOf(FormData);
});
});
describe('canSubmit getter', () => {
it('should return true when file is valid and no errors', () => {
component.file = new File(['test'], 'test.pdf', { type: 'application/pdf' });
component.fileError = null;
expect(component.canSubmit).toBe(true);
});
it('should return false when no file selected', () => {
component.file = null;
expect(component.canSubmit).toBe(false);
});
it('should return false when file has error', () => {
component.file = new File(['test'], 'test.pdf', { type: 'application/pdf' });
component.fileError = 'Some error';
expect(component.canSubmit).toBe(false);
});
});
});

View File

@@ -1,48 +1,102 @@
import { Component, effect, input } from '@angular/core';
import { NgClass } from '@angular/common';
import { NgTemplateOutlet } from '@angular/common';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
import { ProfileFacade } from '@app/ui/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';
@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 {
profile = input.required<ProfileViewModel>();
file: File | null = null; // Variable to store file
file: File | null = null;
fileError: string | null = null;
private readonly MAX_FILE_SIZE = 5 * 1024 * 1024; // 5Mo en bytes
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) {
if (!this.loading().isLoading && !this.error().hasError) {
this.resetFile();
}
break;
}
});
}
onSubmit() {
if (this.file != null) {
onSubmit(): void {
if (this.file != null && !this.fileError) {
const formData = new FormData();
formData.append('cv', this.file); // "avatar" est le nom du champ dans PocketBase
formData.append('cv', this.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];
onFileChange($event: Event): void {
const target = $event.target as HTMLInputElement;
const selectedFile = target?.files?.[0];
if (!selectedFile) {
return;
}
// Réinitialiser l'erreur
this.fileError = null;
// Vérifier le type de fichier
if (selectedFile.type !== 'application/pdf') {
this.fileError = 'Le fichier doit être au format PDF';
this.file = null;
target.value = ''; // Réinitialiser l'input
return;
}
// Vérifier la taille du fichier
if (selectedFile.size > this.MAX_FILE_SIZE) {
this.fileError = `Le fichier est trop volumineux (${this.formatFileSize(selectedFile.size)}). Taille maximale autorisée : 5 Mo`;
this.file = null;
target.value = ''; // Réinitialiser l'input
return;
}
this.file = selectedFile;
}
removeFile(): void {
this.resetFile();
}
private resetFile(): void {
this.file = null;
this.fileError = null;
}
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];
}
get canSubmit(): boolean {
return (
this.file != null && !this.fileError && !this.error().hasError && !this.loading().isLoading
);
}
}

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)="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 !== null) {
<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">
{{ 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)="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

@@ -7,15 +7,18 @@ import { PROJECT_REPOSITORY_TOKEN } from '@app/infrastructure/projects/project-r
import { ProjectRepository } from '@app/domain/projects/project.repository';
import { mockToastR } from '@app/testing/toastr.mock';
import { mockProjectRepo } from '@app/testing/project.mock';
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
describe('ProjectPictureFormComponent', () => {
let component: ProjectPictureFormComponent;
let fixture: ComponentFixture<ProjectPictureFormComponent>;
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>> = mockProjectRepo;
let mockToastrService: jest.Mocked<Partial<ToastrService>>;
let mockProjectRepository: jest.Mocked<Partial<ProjectRepository>>;
beforeEach(async () => {
mockToastrService = mockToastR;
mockProjectRepository = mockProjectRepo;
await TestBed.configureTestingModule({
imports: [ProjectPictureFormComponent],
providers: [
@@ -27,10 +30,267 @@ describe('ProjectPictureFormComponent', () => {
fixture = TestBed.createComponent(ProjectPictureFormComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('project', {} as ProfileViewModel);
fixture.detectChanges();
});
it('should create', () => {
it('devrait créer le composant', () => {
expect(component).toBeTruthy();
});
describe('Validation du fichier', () => {
it('devrait accepter une image JPEG valide de moins de 5Mo', () => {
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 }); // 1MB
const event = {
target: {
files: [validFile],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBe(validFile);
expect(component.fileError).toBeNull();
});
it('devrait accepter une image PNG valide', () => {
const validFile = new File(['test content'], 'test.png', { type: 'image/png' });
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 });
const event = {
target: {
files: [validFile],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBe(validFile);
expect(component.fileError).toBeNull();
});
it('devrait accepter une image WebP valide', () => {
const validFile = new File(['test content'], 'test.webp', { type: 'image/webp' });
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 });
const event = {
target: {
files: [validFile],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBe(validFile);
expect(component.fileError).toBeNull();
});
it('devrait rejeter un fichier supérieur à 5Mo', () => {
const largeFile = new File(['test content'], 'large.jpg', { type: 'image/jpeg' });
Object.defineProperty(largeFile, 'size', { value: 6 * 1024 * 1024 }); // 6MB
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [largeFile], writable: true });
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toContain('trop volumineuse');
expect(component.fileError).toContain('5 Mo');
});
it("devrait rejeter un fichier qui n'est pas une image", () => {
const invalidFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [invalidFile], writable: true });
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toContain('image');
expect(component.fileError).toContain('JPEG, PNG ou WebP');
});
it('devrait rejeter une image avec une extension non autorisée', () => {
const invalidFile = new File(['test content'], 'test.gif', { type: 'image/gif' });
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [invalidFile], writable: true });
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toBeTruthy();
});
it("devrait gérer l'absence de fichier sélectionné", () => {
const event = {
target: {
files: [],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toBeNull();
});
});
describe('Suppression du fichier', () => {
it('devrait supprimer le fichier et réinitialiser les erreurs', () => {
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
component.file = validFile;
component.imagePreviewUrl = 'data:image/jpeg;base64,test';
component.fileError = 'Une erreur';
component.removeFile();
expect(component.file).toBeNull();
expect(component.imagePreviewUrl).toBeNull();
expect(component.fileError).toBeNull();
});
});
describe('Formatage de la taille du fichier', () => {
it('devrait formater correctement les octets', () => {
expect(component.formatFileSize(0)).toBe('0 Octets');
expect(component.formatFileSize(1024)).toBe('1 Ko');
expect(component.formatFileSize(1024 * 1024)).toBe('1 Mo');
expect(component.formatFileSize(1536 * 1024)).toBe('1.5 Mo');
expect(component.formatFileSize(2.5 * 1024 * 1024)).toBe('2.5 Mo');
});
});
describe('Soumission du formulaire', () => {
it("ne devrait pas soumettre si aucun fichier n'est sélectionné", () => {
const spy = jest.spyOn(component['projectFacade'], 'update');
component.file = null;
component.imagePreviewUrl = null;
component.onSubmit();
expect(spy).not.toHaveBeenCalled();
});
it("ne devrait pas soumettre s'il y a une erreur de fichier", () => {
const spy = jest.spyOn(component['projectFacade'], 'update');
component.file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
component.fileError = 'Fichier trop volumineux';
component.onSubmit();
expect(spy).not.toHaveBeenCalled();
});
//todo : test : devrait soumettre un fichier valide avec FormData ( project picture )
/*it('devrait soumettre un fichier valide avec FormData', () => {
const spy = jest.spyOn(component['projectFacade'], 'update');
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
component.project = { id: 'test-id', nom: 'Test Project' } as any;
component.file = validFile;
component.fileError = null;
component.onSubmit();
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0];
expect(callArgs[0]).toBe('test-id');
expect(callArgs[1]).toBeInstanceOf(FormData);
});*/
});
describe('Getter canSubmit', () => {
it('devrait retourner true quand le fichier est valide et sans erreurs', () => {
component.file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
component.fileError = null;
expect(component.canSubmit).toBe(true);
});
it('devrait retourner true quand imagePreviewUrl existe', () => {
component.file = null;
component.imagePreviewUrl = 'data:image/jpeg;base64,test';
component.fileError = null;
expect(component.canSubmit).toBe(true);
});
it("devrait retourner false quand aucun fichier n'est sélectionné", () => {
component.file = null;
component.imagePreviewUrl = null;
expect(component.canSubmit).toBe(false);
});
it('devrait retourner false quand il y a une erreur de fichier', () => {
component.file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
component.fileError = 'Une erreur';
expect(component.canSubmit).toBe(false);
});
});
//todo : test : Getter currentImageUrl ( project picture )
/*describe('Getter currentImageUrl', () => {
it('devrait retourner imagePreviewUrl si elle existe', () => {
component.imagePreviewUrl = 'data:image/jpeg;base64,preview';
component.project = { id: '123', fichier: 'old.jpg', nom: 'Test' } as any;
expect(component.currentImageUrl).toBe('data:image/jpeg;base64,preview');
});
it('devrait retourner l\'URL du projet si le fichier existe', () => {
component.imagePreviewUrl = null;
component.project = { id: '123', fichier: 'project.jpg', nom: 'Test' } as any;
const url = component.currentImageUrl;
expect(url).toContain('project.jpg');
expect(url).toContain('123');
});
it('devrait retourner une URL Dicebear si pas de fichier mais nom existe', () => {
component.imagePreviewUrl = null;
component.project = { id: '123', nom: 'Test Project' } as any;
const url = component.currentImageUrl;
expect(url).toContain('dicebear.com');
expect(url).toContain('Test Project');
});
it('devrait retourner une URL Dicebear par défaut si pas de projet', () => {
component.imagePreviewUrl = null;
const url = component.currentImageUrl;
expect(url).toContain('dicebear.com');
expect(url).toContain('nouveau-projet');
});
});*/
describe('Lecture du fichier', () => {
it('devrait lire le fichier et créer une preview', (done) => {
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
component['readFile'](validFile);
// Attendre que FileReader se termine
setTimeout(() => {
expect(component.imagePreviewUrl).toBeTruthy();
expect(typeof component.imagePreviewUrl).toBe('string');
done();
}, 100);
});
});
});

View File

@@ -1,5 +1,5 @@
import { Component, effect, Input, output, signal } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { Component, effect, input, output, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { environment } from '@env/environment';
import { ProjectViewModel } from '@app/ui/projects/project.presenter.model';
import { ProjectFacade } from '@app/ui/projects/project.facade';
@@ -9,12 +9,12 @@ import { LoadingComponent } from '@app/shared/components/loading/loading.compone
@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;
@@ -26,16 +26,21 @@ export class ProjectPictureFormComponent {
file: File | null = null; // Variable to store file
imagePreviewUrl: string | null = null; // URL for image preview
fileError: string | null = null;
protected onSubmitted = signal<boolean>(false);
private readonly MAX_FILE_SIZE = 5 * 1024 * 1024; // 5Mo en bytes
private readonly ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
private readonly ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
constructor() {
let message = '';
effect(() => {
if (!this.loading().isLoading && this.onSubmitted()) {
switch (this.loading().action) {
case ActionType.UPDATE:
this.onSubmitted.set(false);
this.onFormSubmitted.emit('');
this.resetFile();
break;
}
}
@@ -43,28 +48,109 @@ export class ProjectPictureFormComponent {
}
onSubmit() {
if (this.file != null) {
if (this.file != null && !this.fileError) {
const formData = new FormData();
formData.append('fichier', this.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);
onPictureChange($event: Event): void {
const target = $event.target as HTMLInputElement;
const selectedFile = target?.files?.[0];
if (!selectedFile) {
return;
}
// Réinitialiser l'erreur
this.fileError = null;
// Vérifier le type de fichier
if (!this.ALLOWED_IMAGE_TYPES.includes(selectedFile.type)) {
this.resetFile();
this.fileError = '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 = '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 = `L'image est trop volumineuse (${this.formatFileSize(selectedFile.size)}). Taille maximale autorisée : 5 Mo`;
target.value = '';
return;
}
this.file = selectedFile;
this.readFile(this.file);
}
private readFile(file: File) {
removeFile(): void {
this.resetFile();
}
private resetFile(): void {
this.file = null;
this.imagePreviewUrl = null;
this.fileError = null;
}
private readFile(file: File): void {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewUrl = e.target?.result as string;
};
reader.onerror = () => {
this.fileError = 'Erreur lors de la lecture du fichier';
this.resetFile();
};
reader.readAsDataURL(file);
}
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];
}
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.environment.baseUrl}/api/files/projets/${this.project()!.id}/${this.project()!.fichier}`;
}
if (this.project()?.nom) {
return `https://api.dicebear.com/9.x/shapes/svg?seed=${this.project()!.nom}`;
}
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() !== undefined) {
<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)="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 !== null) {
<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">
{{ 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)="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

@@ -7,15 +7,26 @@ 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 '@app/ui/users/user.presenter.model';
describe('UserAvatarFormComponent', () => {
let component: UserAvatarFormComponent;
let fixture: ComponentFixture<UserAvatarFormComponent>;
let mockToastrService: jest.Mocked<Partial<ToastrService>>;
let mockUserRepository: jest.Mocked<Partial<UserRepository>>;
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
let mockUserRepository: jest.Mocked<Partial<UserRepository>> = mockUserRepo;
const mockUser: UserViewModel = {
id: '123',
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
avatar: '',
} as UserViewModel;
beforeEach(async () => {
mockToastrService = mockToastR;
mockUserRepository = mockUserRepo;
await TestBed.configureTestingModule({
imports: [UserAvatarFormComponent],
providers: [
@@ -27,10 +38,313 @@ describe('UserAvatarFormComponent', () => {
fixture = TestBed.createComponent(UserAvatarFormComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('user', mockUser);
fixture.detectChanges();
});
it('should create', () => {
it('devrait créer le composant', () => {
expect(component).toBeTruthy();
});
describe('Validation du fichier', () => {
it('devrait accepter une image JPEG valide de moins de 5Mo', () => {
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 }); // 1MB
const event = {
target: {
files: [validFile],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBe(validFile);
expect(component.fileError).toBeNull();
});
it('devrait accepter une image PNG valide', () => {
const validFile = new File(['test content'], 'test.png', { type: 'image/png' });
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 });
const event = {
target: {
files: [validFile],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBe(validFile);
expect(component.fileError).toBeNull();
});
it('devrait accepter une image WebP valide', () => {
const validFile = new File(['test content'], 'test.webp', { type: 'image/webp' });
Object.defineProperty(validFile, 'size', { value: 1024 * 1024 });
const event = {
target: {
files: [validFile],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBe(validFile);
expect(component.fileError).toBeNull();
});
it('devrait rejeter un fichier supérieur à 5Mo', () => {
const largeFile = new File(['test content'], 'large.jpg', { type: 'image/jpeg' });
Object.defineProperty(largeFile, 'size', { value: 6 * 1024 * 1024 }); // 6MB
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [largeFile], writable: true });
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toContain('trop volumineuse');
expect(component.fileError).toContain('5 Mo');
});
it("devrait rejeter un fichier qui n'est pas une image", () => {
const invalidFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [invalidFile], writable: true });
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toContain('image');
expect(component.fileError).toContain('JPEG, PNG ou WebP');
});
it('devrait rejeter une image avec une extension non autorisée', () => {
const invalidFile = new File(['test content'], 'test.gif', { type: 'image/gif' });
const mockInput = document.createElement('input');
const event = {
target: mockInput,
} as any;
Object.defineProperty(mockInput, 'files', { value: [invalidFile], writable: true });
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toBeTruthy();
});
it("devrait gérer l'absence de fichier sélectionné", () => {
const event = {
target: {
files: [],
},
} as any;
component.onPictureChange(event);
expect(component.file).toBeNull();
expect(component.fileError).toBeNull();
});
});
describe('Suppression du fichier', () => {
it('devrait supprimer le fichier et réinitialiser les erreurs', () => {
component.file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
component.imagePreviewUrl = 'data:image/jpeg;base64,test';
component.fileError = 'Une erreur';
component.removeFile();
expect(component.file).toBeNull();
expect(component.imagePreviewUrl).toBeNull();
expect(component.fileError).toBeNull();
});
});
describe('Formatage de la taille du fichier', () => {
it('devrait formater correctement les octets', () => {
expect(component.formatFileSize(0)).toBe('0 Octets');
expect(component.formatFileSize(1024)).toBe('1 Ko');
expect(component.formatFileSize(1024 * 1024)).toBe('1 Mo');
expect(component.formatFileSize(1536 * 1024)).toBe('1.5 Mo');
expect(component.formatFileSize(2.5 * 1024 * 1024)).toBe('2.5 Mo');
});
});
// todo : Soumission du formulaire (user-avatar)
/*describe('Soumission du formulaire', () => {
it('ne devrait pas soumettre si aucun fichier n\'est sélectionné', () => {
const spy = jest.spyOn(component['facade'], 'update');
component.file = null;
component.imagePreviewUrl = null;
component.onUserAvatarFormSubmit();
expect(spy).not.toHaveBeenCalled();
});
it('ne devrait pas soumettre s\'il y a une erreur de fichier', () => {
const spy = jest.spyOn(component['facade'], 'update');
component.file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
component.fileError = 'Fichier trop volumineux';
component.onUserAvatarFormSubmit();
expect(spy).not.toHaveBeenCalled();
});
it('devrait soumettre un fichier valide avec FormData', () => {
const spy = jest.spyOn(component['facade'], 'update');
component.file = new File(['test content'], 'test.jpg', {type: 'image/jpeg'});
component.fileError = null;
component.onUserAvatarFormSubmit();
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0];
expect(callArgs[0]).toBe('123');
expect(callArgs[1]).toBeInstanceOf(FormData);
});
it('ne devrait pas soumettre si l\'utilisateur n\'a pas d\'ID', () => {
const spy = jest.spyOn(component['facade'], 'update');
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
fixture.componentRef.setInput('user', undefined);
component.file = validFile;
component.fileError = null;
component.onUserAvatarFormSubmit();
expect(spy).not.toHaveBeenCalled();
});
});*/
describe('Getter canSubmit', () => {
it('devrait retourner true quand le fichier est valide et sans erreurs', () => {
component.file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
component.fileError = null;
expect(component.canSubmit).toBe(true);
});
it('devrait retourner true quand imagePreviewUrl existe', () => {
component.file = null;
component.imagePreviewUrl = 'data:image/jpeg;base64,test';
component.fileError = null;
expect(component.canSubmit).toBe(true);
});
it("devrait retourner false quand aucun fichier n'est sélectionné", () => {
component.file = null;
component.imagePreviewUrl = null;
expect(component.canSubmit).toBe(false);
});
it('devrait retourner false quand il y a une erreur de fichier', () => {
component.file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
component.fileError = 'Une erreur';
expect(component.canSubmit).toBe(false);
});
});
describe('Getter currentAvatarUrl', () => {
it('devrait retourner imagePreviewUrl si elle existe', () => {
component.imagePreviewUrl = 'data:image/jpeg;base64,preview';
expect(component.currentAvatarUrl).toBe('data:image/jpeg;base64,preview');
});
it("devrait retourner l'URL de l'avatar si elle existe", () => {
const userWithAvatar = {
...mockUser,
avatar: 'avatar.jpg',
} as UserViewModel;
fixture.componentRef.setInput('user', userWithAvatar);
fixture.detectChanges();
component.imagePreviewUrl = null;
const url = component.currentAvatarUrl;
expect(url).toContain('avatar.jpg');
expect(url).toContain('123');
});
it("devrait retourner une URL Dicebear avec le nom si pas d'avatar", () => {
component.imagePreviewUrl = null;
const url = component.currentAvatarUrl;
expect(url).toContain('dicebear.com');
expect(url).toContain('Test User');
});
it('devrait retourner une URL Dicebear avec username si pas de nom', () => {
const userWithoutName = {
...mockUser,
name: '',
} as UserViewModel;
fixture.componentRef.setInput('user', userWithoutName);
fixture.detectChanges();
component.imagePreviewUrl = null;
const url = component.currentAvatarUrl;
expect(url).toContain('dicebear.com');
expect(url).toContain('testuser');
});
it('devrait retourner une URL Dicebear avec email si pas de nom ni username', () => {
const userWithEmailOnly = {
...mockUser,
name: '',
username: '',
} as UserViewModel;
fixture.componentRef.setInput('user', userWithEmailOnly);
fixture.detectChanges();
component.imagePreviewUrl = null;
const url = component.currentAvatarUrl;
expect(url).toContain('dicebear.com');
expect(url).toContain('test@example.com');
});
it("devrait retourner null si pas d'utilisateur", () => {
fixture.componentRef.setInput('user', undefined);
fixture.detectChanges();
component.imagePreviewUrl = null;
expect(component.currentAvatarUrl).toBeNull();
});
});
describe('Lecture du fichier', () => {
it('devrait lire le fichier et créer une preview', (done) => {
const validFile = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
component['readFile'](validFile);
// Attendre que FileReader se termine
setTimeout(() => {
expect(component.imagePreviewUrl).toBeTruthy();
expect(typeof component.imagePreviewUrl).toBe('string');
done();
}, 100);
});
});
});

View File

@@ -1,9 +1,8 @@
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 { NgTemplateOutlet } from '@angular/common';
import { UserFacade } from '@app/ui/users/user.facade';
import { ActionType } from '@app/domain/action-type.util';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
@@ -14,34 +13,37 @@ import { UserViewModel } from '@app/ui/users/user.presenter.model';
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;
private readonly facade = inject(UserFacade);
protected readonly ActionType = ActionType;
@Input({ required: true }) user: UserViewModel | undefined = undefined;
user = input.required<UserViewModel | undefined>();
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'];
file: File | null = null; // Variable to store file
imagePreviewUrl: string | null = null; // URL for image preview
fileError: string | null = null;
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:
if (this.onSubmitted()) {
this.customToast(ActionType.UPDATE, message);
this.resetFile();
}
break;
}
@@ -49,56 +51,113 @@ export class UserAvatarFormComponent {
});
}
onUserAvatarFormSubmit() {
if (this.file != null) {
onUserAvatarFormSubmit(): void {
if (this.file != null && !this.fileError && this.user()?.id) {
const formData = new FormData();
formData.append('avatar', this.file); // "avatar" est le nom du champ dans PocketBase
formData.append('avatar', this.file);
this.facade.update(this.user!.id, formData as Partial<User>);
this.facade.update(this.user()!.id, formData 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);
onPictureChange($event: Event): void {
const target = $event.target as HTMLInputElement;
const selectedFile = target?.files?.[0];
if (!selectedFile) {
return;
}
// Réinitialiser l'erreur
this.fileError = null;
// Vérifier le type de fichier
if (!this.ALLOWED_IMAGE_TYPES.includes(selectedFile.type)) {
this.resetFile();
this.fileError = '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 = '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 = `L'image est trop volumineuse (${this.formatFileSize(selectedFile.size)}). Taille maximale autorisée : 5 Mo`;
target.value = '';
return;
}
this.file = selectedFile;
this.readFile(this.file);
}
private readFile(file: File) {
removeFile(): void {
this.resetFile();
}
private resetFile(): void {
this.file = null;
this.imagePreviewUrl = null;
this.fileError = null;
}
private readFile(file: File): void {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewUrl = e.target?.result as string;
};
reader.onerror = () => {
this.fileError = 'Erreur lors de la lecture du fichier';
this.resetFile();
};
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;
}
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Octets';
this.toastrService.success(
`${message}`,
`${action === ActionType.UPDATE ? 'Mise à jour' : ''}`,
{
closeButton: true,
progressAnimation: 'decreasing',
progressBar: true,
}
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];
}
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 `${this.environment.baseUrl}/api/files/users/${currentUser.id}/${currentUser.avatar}`;
}
if (currentUser) {
const seed = currentUser.name || currentUser.username || currentUser.email || 'user';
return `https://api.dicebear.com/9.x/initials/svg?seed=${seed}`;
}
return null;
}
}

View File

@@ -1,42 +1,43 @@
@if (cv_link()) {
@if (hasCv()) {
<!-- Container du PDF viewer -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden animate-fade-in">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<!-- Header -->
<div
class="bg-gradient-to-r from-indigo-600 to-purple-600 px-6 py-4 flex items-center justify-between"
class="bg-gradient-to-r from-indigo-600 to-purple-600 px-4 py-3 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<div
class="w-10 h-10 bg-white/20 backdrop-blur-md rounded-lg flex items-center justify-center"
class="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-white"
class="w-5 h-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="text-lg font-bold text-white">Mon CV</h3>
<h3 class="text-base sm:text-lg font-semibold text-white">Mon CV</h3>
</div>
<!-- Bouton télécharger -->
<a
[href]="cv_link()"
[href]="cvUrl()"
download
target="_blank"
class="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-md rounded-lg text-white text-sm font-medium transition-all duration-200 hover:scale-105"
class="flex items-center gap-2 px-3 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg text-white text-sm font-medium transition-all duration-200 hover:scale-105"
aria-label="Télécharger le CV"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
class="h-4 w-4 sm:h-5 sm:w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
@@ -51,51 +52,56 @@
</div>
<!-- PDF Viewer -->
<div class="p-4 bg-gray-100 dark:bg-gray-900">
<pdf-viewer
[src]="cv_link()!"
[zoom]="1"
[rotation]="0"
[original-size]="false"
[show-all]="true"
[fit-to-page]="false"
[zoom-scale]="'page-width'"
[stick-to-page]="false"
[render-text]="true"
[external-link-target]="'blank'"
[autoresize]="true"
[show-borders]="true"
class="rounded-lg overflow-hidden shadow-inner"
/>
<div class="p-4 bg-gray-50 dark:bg-gray-900 min-h-[500px]">
<div class="bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-sm">
<pdf-viewer
[src]="cvUrl()!"
[zoom]="1"
[rotation]="0"
[original-size]="false"
[show-all]="true"
[fit-to-page]="false"
[zoom-scale]="'page-width'"
[stick-to-page]="false"
[render-text]="true"
[external-link-target]="'blank'"
[autoresize]="true"
[show-borders]="true"
/>
</div>
</div>
</div>
} @else {
<!-- Message si aucun CV -->
<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 animate-fade-in"
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-8 sm:p-12 text-center border-2 border-dashed border-gray-300 dark:border-gray-600"
>
<div
class="inline-flex w-20 h-20 bg-white dark:bg-gray-600 rounded-full items-center justify-center mb-6 shadow-lg"
class="inline-flex w-16 h-16 sm:w-20 sm:h-20 bg-gray-100 dark:bg-gray-700 rounded-full items-center justify-center mb-4 sm:mb-6"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-10 h-10 text-gray-400 dark:text-gray-500"
class="w-8 h-8 sm:w-10 sm:h-10 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Aucun CV disponible</h3>
<p class="text-gray-600 dark:text-gray-300 max-w-md mx-auto">
Aucun curriculum vitae n'a été ajouté pour le moment. Veuillez télécharger votre CV pour le
visualiser ici.
<h3 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white mb-2">
Aucun CV disponible
</h3>
<p class="text-sm sm:text-base text-gray-600 dark:text-gray-400 max-w-md mx-auto">
Vous n'avez pas encore ajouté de CV. Téléchargez votre curriculum vitae pour le visualiser
ici.
</p>
</div>
}

View File

@@ -7,11 +7,16 @@ describe('PdfViewerComponent', () => {
let component: PdfViewerComponent;
let fixture: ComponentFixture<PdfViewerComponent>;
const mockProfile: ProfileViewModel = {
const mockProfileWithCv: ProfileViewModel = {
id: '123',
cv: 'cvfilename.pdf',
} as ProfileViewModel;
const mockProfileWithoutCv: ProfileViewModel = {
id: '123',
cv: '',
} as ProfileViewModel;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PdfViewerComponent],
@@ -19,15 +24,130 @@ describe('PdfViewerComponent', () => {
fixture = TestBed.createComponent(PdfViewerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('profile', mockProfile);
fixture.detectChanges();
await fixture.whenStable();
});
it('should create', () => {
fixture.componentRef.setInput('profile', mockProfileWithCv);
fixture.detectChanges();
expect(component).toBeTruthy();
});
describe('CV URL generation', () => {
it('should generate correct URL when CV exists', async () => {
fixture.componentRef.setInput('profile', mockProfileWithCv);
fixture.detectChanges();
await fixture.whenStable();
const cvUrl = component['cvUrl']();
expect(cvUrl).toBeTruthy();
expect(cvUrl).toContain('cvfilename.pdf');
});
it('should return null when CV does not exist', async () => {
fixture.componentRef.setInput('profile', mockProfileWithoutCv);
fixture.detectChanges();
await fixture.whenStable();
const cvUrl = component['cvUrl']();
expect(cvUrl).toBeNull();
});
it('should handle profile with undefined cv', async () => {
const profileWithUndefinedCv = {
id: '123',
cv: '',
} as ProfileViewModel;
fixture.componentRef.setInput('profile', profileWithUndefinedCv);
fixture.detectChanges();
await fixture.whenStable();
const cvUrl = component['cvUrl']();
expect(cvUrl).toBeNull();
});
it('should return full URL when CV is already a complete URL', async () => {
const profileWithFullUrl = {
id: '123',
cv: 'https://example.com/cv.pdf',
} as ProfileViewModel;
fixture.componentRef.setInput('profile', profileWithFullUrl);
fixture.detectChanges();
await fixture.whenStable();
const cvUrl = component['cvUrl']();
expect(cvUrl).toBe('https://example.com/cv.pdf');
});
});
describe('hasCv computed', () => {
it('should return true when CV exists', async () => {
fixture.componentRef.setInput('profile', mockProfileWithCv);
fixture.detectChanges();
await fixture.whenStable();
expect(component['hasCv']()).toBe(true);
});
it('should return false when CV does not exist', async () => {
fixture.componentRef.setInput('profile', mockProfileWithoutCv);
fixture.detectChanges();
await fixture.whenStable();
expect(component['hasCv']()).toBe(false);
});
});
describe('Template rendering', () => {
it('should display PDF viewer when CV exists', async () => {
fixture.componentRef.setInput('profile', mockProfileWithCv);
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
const pdfViewer = compiled.querySelector('pdf-viewer');
const noDataMessage = compiled.querySelector('h3');
expect(pdfViewer).toBeTruthy();
expect(noDataMessage?.textContent).not.toContain('Aucun CV disponible');
});
it('should display "no CV" message when CV does not exist', async () => {
fixture.componentRef.setInput('profile', mockProfileWithoutCv);
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
const pdfViewer = compiled.querySelector('pdf-viewer');
const messageTitle = compiled.querySelector('h3');
expect(pdfViewer).toBeNull();
expect(messageTitle?.textContent?.trim()).toBe('Aucun CV disponible');
});
it('should display download button when CV exists', async () => {
fixture.componentRef.setInput('profile', mockProfileWithCv);
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
const downloadButton = compiled.querySelector('a[download]');
expect(downloadButton).toBeTruthy();
expect(downloadButton?.getAttribute('href')).toBeTruthy();
});
it('should not display download button when CV does not exist', async () => {
fixture.componentRef.setInput('profile', mockProfileWithoutCv);
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
const downloadButton = compiled.querySelector('a[download]');
expect(downloadButton).toBeNull();
});
});
});

View File

@@ -13,7 +13,7 @@ import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
export class PdfViewerComponent {
profile = input.required<ProfileViewModel>();
protected readonly environment = environment;
protected readonly cv_link = computed(() => {
protected readonly cvUrl = computed(() => {
const currentProfile = this.profile();
if (!currentProfile || !currentProfile.cv) {
@@ -22,4 +22,8 @@ export class PdfViewerComponent {
return currentProfile.cv;
});
protected readonly hasCv = computed(() => {
return this.cvUrl() !== null;
});
}

View File

@@ -38,7 +38,7 @@ export class ProfilePresenter {
reseaux: profile.reseaux,
apropos: profile.apropos,
projets: profile.projets,
cv: `${environment.baseUrl}/api/files/profiles/${profile.id}/${profile.cv}`,
cv: profile.cv ? `${environment.baseUrl}/api/files/profiles/${profile.id}/${profile.cv}` : '',
bio: profile.bio,
coordonnees: profile.coordonnees
? { latitude: profile!.coordonnees!.lat!, longitude: profile!.coordonnees!.lon! }