From 2c172dd3d17fc30c4153000c769d544b3048258a Mon Sep 17 00:00:00 2001 From: Gabriel De Los Rios Date: Sat, 7 Feb 2026 23:00:12 -0300 Subject: [PATCH] feat: add custom file input for images --- .../image-uploader/image-uploader.html | 20 ++++++ .../image-uploader/image-uploader.scss | 26 +++++++ .../image-uploader/image-uploader.spec.ts | 69 +++++++++++++++++++ .../image-uploader/image-uploader.ts | 45 ++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 src/app/components/image-uploader/image-uploader.html create mode 100644 src/app/components/image-uploader/image-uploader.scss create mode 100644 src/app/components/image-uploader/image-uploader.spec.ts create mode 100644 src/app/components/image-uploader/image-uploader.ts diff --git a/src/app/components/image-uploader/image-uploader.html b/src/app/components/image-uploader/image-uploader.html new file mode 100644 index 0000000..b2e86a3 --- /dev/null +++ b/src/app/components/image-uploader/image-uploader.html @@ -0,0 +1,20 @@ +@if (imgUrl()) { +
+ +
+} + +
+
+ {{ fileName() || 'common.no_file_yet' | translate | upperfirst }} +
+ +
diff --git a/src/app/components/image-uploader/image-uploader.scss b/src/app/components/image-uploader/image-uploader.scss new file mode 100644 index 0000000..7650d8a --- /dev/null +++ b/src/app/components/image-uploader/image-uploader.scss @@ -0,0 +1,26 @@ +.image-preview { + &__container { + display: flex; + justify-content: center; + margin-bottom: 16px; + } +} + +.file-input { + display: none; +} + +.file-upload { + align-items: center; + box-sizing: border-box; + display: inline-flex; + justify-content: space-between; + min-width: 196px; + width: 100%; + + &__description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/app/components/image-uploader/image-uploader.spec.ts b/src/app/components/image-uploader/image-uploader.spec.ts new file mode 100644 index 0000000..1a16966 --- /dev/null +++ b/src/app/components/image-uploader/image-uploader.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ImageUploader } from './image-uploader'; +import { provideTranslateService } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { FiletypeUtils } from '../../services/filetype-utils'; + +describe('ImageUploader', () => { + let component: ImageUploader; + let fixture: ComponentFixture; + + let filetypeUtils: Partial; + + beforeEach(async () => { + filetypeUtils = { + isValidImageMimeType: vi.fn().mockReturnValue(true) + }; + + await TestBed.configureTestingModule({ + imports: [ImageUploader], + providers: [provideTranslateService(), { provide: FiletypeUtils, useValue: filetypeUtils }], + }).compileComponents(); + + fixture = TestBed.createComponent(ImageUploader); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should update selection when fileIn input changes', async () => { + const file = new File([''], 'test.png', { type: 'image/png' }); + fixture.componentRef.setInput('fileIn', file); + fixture.detectChanges(); + const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description')); + expect((descriptionEl.nativeElement).textContent.toLowerCase().trim()).toEqual( + file.name, + ); + }); + + describe('onFileSelected', () => { + it('should update and emit selection on valid file', () => { + const emitSpy = vi.spyOn(component.file, 'emit'); + const file = new File([''], 'test.png', { type: 'image/png' }); + const inputEvent = { target: { files: [file] } } as any; + component.onFileSelected(inputEvent); + fixture.detectChanges(); + const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description')); + expect( + (descriptionEl.nativeElement).textContent.toLowerCase().trim(), + ).toEqual(file.name); + expect(emitSpy).toHaveBeenCalledOnce(); + }); + + it('should discard file if not valid type', () => { + filetypeUtils.isValidImageMimeType = vi.fn().mockReturnValue(false); + const file = new File([''], 'test.txt', { type: 'text/txt' }); + const inputEvent = { target: { files: [file] } } as any; + component.onFileSelected(inputEvent); + fixture.detectChanges(); + const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description')); + expect( + (descriptionEl.nativeElement).textContent.toLowerCase().trim(), + ).not.toEqual(file.name); + }); + }); +}); diff --git a/src/app/components/image-uploader/image-uploader.ts b/src/app/components/image-uploader/image-uploader.ts new file mode 100644 index 0000000..2e3c105 --- /dev/null +++ b/src/app/components/image-uploader/image-uploader.ts @@ -0,0 +1,45 @@ +import { Component, effect, inject, input, output, signal } from '@angular/core'; +import { MatMiniFabButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { UpperfirstPipe } from '../../pipes/upperfirst-pipe'; +import { FiletypeUtils } from '../../services/filetype-utils'; + +@Component({ + selector: 'app-image-uploader', + imports: [MatIcon, MatMiniFabButton, TranslatePipe, UpperfirstPipe], + templateUrl: './image-uploader.html', + styleUrl: './image-uploader.scss', +}) +export class ImageUploader { + file = output(); + fileIn = input(null); + protected readonly fileName = signal(''); + protected readonly imgUrl = signal(''); + private readonly filetypeUtils = inject(FiletypeUtils); + + constructor() { + effect(() => { + if (this.fileIn()) { + const file = this.fileIn(); + this.updateSelection(file); + } + }); + } + + onFileSelected(e: Partial) { + const files = (e.target).files; + if (files) { + const file = files[0]; + if (file && this.filetypeUtils.isValidImageMimeType(file.type)) { + this.updateSelection(file); + this.file.emit(file); + } + } + } + + private updateSelection(file: File) { + this.fileName.set(file.name); + this.imgUrl.set(URL.createObjectURL(file)); + } +}