refacto : gestion du poids des fichiers a upload
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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! }
|
||||
|
||||
Reference in New Issue
Block a user