diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index ad76bdb74..a97897c36 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -110,6 +110,7 @@ import { DocumentLinkComponent } from './components/common/input/document-link/d import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component' import { SwitchComponent } from './components/common/input/switch/switch.component' import { ConfigComponent } from './components/admin/config/config.component' +import { FileComponent } from './components/common/input/file/file.component' import localeAf from '@angular/common/locales/af' import localeAr from '@angular/common/locales/ar' @@ -267,6 +268,7 @@ function initializeApp(settings: SettingsService) { PreviewPopupComponent, SwitchComponent, ConfigComponent, + FileComponent, ], imports: [ BrowserModule, diff --git a/src-ui/src/app/components/admin/config/config.component.html b/src-ui/src/app/components/admin/config/config.component.html index a3eb0b8ab..ff19da8d8 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -30,6 +30,7 @@ @case (ConfigOptionType.Boolean) { } @case (ConfigOptionType.String) { } @case (ConfigOptionType.JSON) { } + @case (ConfigOptionType.File) { } } diff --git a/src-ui/src/app/components/admin/config/config.component.spec.ts b/src-ui/src/app/components/admin/config/config.component.spec.ts index 5d70881b6..6c5472159 100644 --- a/src-ui/src/app/components/admin/config/config.component.spec.ts +++ b/src-ui/src/app/components/admin/config/config.component.spec.ts @@ -15,12 +15,15 @@ import { SwitchComponent } from '../../common/input/switch/switch.component' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { SelectComponent } from '../../common/input/select/select.component' +import { FileComponent } from '../../common/input/file/file.component' +import { SettingsService } from 'src/app/services/settings.service' describe('ConfigComponent', () => { let component: ConfigComponent let fixture: ComponentFixture let configService: ConfigService let toastService: ToastService + let settingService: SettingsService beforeEach(async () => { await TestBed.configureTestingModule({ @@ -30,6 +33,7 @@ describe('ConfigComponent', () => { SelectComponent, NumberComponent, SwitchComponent, + FileComponent, PageHeaderComponent, ], imports: [ @@ -44,6 +48,7 @@ describe('ConfigComponent', () => { configService = TestBed.inject(ConfigService) toastService = TestBed.inject(ToastService) + settingService = TestBed.inject(SettingsService) fixture = TestBed.createComponent(ConfigComponent) component = fixture.componentInstance fixture.detectChanges() @@ -100,4 +105,39 @@ describe('ConfigComponent', () => { component.configForm.patchValue({ user_args: '{ "foo": "bar" }' }) expect(component.errors).toEqual({ user_args: null }) }) + + it('should upload file, show error if necessary', () => { + const uploadSpy = jest.spyOn(configService, 'uploadFile') + const errorSpy = jest.spyOn(toastService, 'showError') + uploadSpy.mockReturnValueOnce( + throwError(() => new Error('Error uploading file')) + ) + component.uploadFile(new File([], 'test.png'), 'app_logo') + expect(uploadSpy).toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalled() + uploadSpy.mockReturnValueOnce( + of({ app_logo: 'https://example.com/logo/test.png' } as any) + ) + component.uploadFile(new File([], 'test.png'), 'app_logo') + expect(component.initialConfig).toEqual({ + app_logo: 'https://example.com/logo/test.png', + }) + }) + + it('should refresh ui settings after save or upload', () => { + const saveSpy = jest.spyOn(configService, 'saveConfig') + const initSpy = jest.spyOn(settingService, 'initializeSettings') + saveSpy.mockReturnValueOnce( + of({ output_type: OutputTypeConfig.PDF_A } as any) + ) + component.saveConfig() + expect(initSpy).toHaveBeenCalled() + + const uploadSpy = jest.spyOn(configService, 'uploadFile') + uploadSpy.mockReturnValueOnce( + of({ app_logo: 'https://example.com/logo/test.png' } as any) + ) + component.uploadFile(new File([], 'test.png'), 'app_logo') + expect(initSpy).toHaveBeenCalled() + }) }) diff --git a/src-ui/src/app/components/admin/config/config.component.ts b/src-ui/src/app/components/admin/config/config.component.ts index 66d7b537f..63e66d456 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -19,6 +19,7 @@ import { ConfigService } from 'src/app/services/config.service' import { ToastService } from 'src/app/services/toast.service' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms' +import { SettingsService } from 'src/app/services/settings.service' @Component({ selector: 'pngx-config', @@ -55,7 +56,8 @@ export class ConfigComponent constructor( private configService: ConfigService, - private toastService: ToastService + private toastService: ToastService, + private settingsService: SettingsService ) { super() this.configForm.addControl('id', new FormControl()) @@ -145,6 +147,7 @@ export class ConfigComponent this.loading = false this.initialize(config) this.store.next(config) + this.settingsService.initializeSettings().subscribe() this.toastService.showInfo($localize`Configuration updated`) }, error: (e) => { @@ -160,4 +163,27 @@ export class ConfigComponent public discardChanges() { this.configForm.reset(this.initialConfig) } + + public uploadFile(file: File, key: string) { + this.loading = true + this.configService + .uploadFile(file, this.configForm.value['id'], key) + .pipe(takeUntil(this.unsubscribeNotifier), first()) + .subscribe({ + next: (config) => { + this.loading = false + this.initialize(config) + this.store.next(config) + this.settingsService.initializeSettings().subscribe() + this.toastService.showInfo($localize`File successfully updated`) + }, + error: (e) => { + this.loading = false + this.toastService.showError( + $localize`An error occurred uploading file`, + e + ) + }, + }) + } } diff --git a/src-ui/src/app/components/common/input/file/file.component.html b/src-ui/src/app/components/common/input/file/file.component.html new file mode 100644 index 000000000..121bda083 --- /dev/null +++ b/src-ui/src/app/components/common/input/file/file.component.html @@ -0,0 +1,30 @@ +
+
+
+ @if (title) { + + } + @if (removable) { + + } +
+
+ + +
+ @if (filename) { + {{filename}} + } + + @if (hint) { + + } +
+ {{error}} +
+
+
diff --git a/src-ui/src/app/components/common/input/file/file.component.scss b/src-ui/src/app/components/common/input/file/file.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/common/input/file/file.component.spec.ts b/src-ui/src/app/components/common/input/file/file.component.spec.ts new file mode 100644 index 000000000..ad3f70e41 --- /dev/null +++ b/src-ui/src/app/components/common/input/file/file.component.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { FileComponent } from './file.component' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' + +describe('FileComponent', () => { + let component: FileComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FileComponent], + imports: [FormsModule, ReactiveFormsModule, HttpClientTestingModule], + }).compileComponents() + + fixture = TestBed.createComponent(FileComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should update file on change', () => { + const event = { target: { files: [new File([], 'test.png')] } } + component.onFile(event as any) + expect(component.file.name).toEqual('test.png') + }) + + it('should get filename', () => { + component.value = 'https://example.com:8000/logo/filename.svg' + expect(component.filename).toEqual('filename.svg') + }) + + it('should fire upload event', () => { + let firedFile + component.file = new File([], 'test.png') + component.upload.subscribe((file) => (firedFile = file)) + component.onButton() + expect(firedFile.name).toEqual('test.png') + expect(component.file).toBeUndefined() + }) +}) diff --git a/src-ui/src/app/components/common/input/file/file.component.ts b/src-ui/src/app/components/common/input/file/file.component.ts new file mode 100644 index 000000000..c27a009a4 --- /dev/null +++ b/src-ui/src/app/components/common/input/file/file.component.ts @@ -0,0 +1,47 @@ +import { + Component, + ElementRef, + EventEmitter, + Output, + ViewChild, + forwardRef, +} from '@angular/core' +import { NG_VALUE_ACCESSOR } from '@angular/forms' +import { AbstractInputComponent } from '../abstract-input' + +@Component({ + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FileComponent), + multi: true, + }, + ], + selector: 'pngx-input-file', + templateUrl: './file.component.html', + styleUrl: './file.component.scss', +}) +export class FileComponent extends AbstractInputComponent { + @Output() + upload = new EventEmitter() + + public file: File + + @ViewChild('fileInput') fileInput: ElementRef + + get filename(): string { + return this.value + ? this.value.substring(this.value.lastIndexOf('/') + 1) + : null + } + + onFile(event: Event) { + this.file = (event.target as HTMLInputElement).files[0] + } + + onButton() { + this.upload.emit(this.file) + this.file = undefined + this.fileInput.nativeElement.value = null + } +} diff --git a/src-ui/src/app/components/common/logo/logo.component.spec.ts b/src-ui/src/app/components/common/logo/logo.component.spec.ts index 921ea3765..5b64177a4 100644 --- a/src-ui/src/app/components/common/logo/logo.component.spec.ts +++ b/src-ui/src/app/components/common/logo/logo.component.spec.ts @@ -2,15 +2,21 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { LogoComponent } from './logo.component' import { By } from '@angular/platform-browser' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { SettingsService } from 'src/app/services/settings.service' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' describe('LogoComponent', () => { let component: LogoComponent let fixture: ComponentFixture + let settingsService: SettingsService beforeEach(() => { TestBed.configureTestingModule({ declarations: [LogoComponent], + imports: [HttpClientTestingModule], }) + settingsService = TestBed.inject(SettingsService) fixture = TestBed.createComponent(LogoComponent) component = fixture.componentInstance fixture.detectChanges() @@ -33,4 +39,9 @@ describe('LogoComponent', () => { 'height:10em' ) }) + + it('should support getting custom logo', () => { + settingsService.set(SETTINGS_KEYS.APP_LOGO, '/logo/test.png') + expect(component.customLogo).toEqual('http://localhost:8000/logo/test.png') + }) }) diff --git a/src-ui/src/app/components/not-found/not-found.component.spec.ts b/src-ui/src/app/components/not-found/not-found.component.spec.ts index 2a0ab9d7c..bd3975670 100644 --- a/src-ui/src/app/components/not-found/not-found.component.spec.ts +++ b/src-ui/src/app/components/not-found/not-found.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { NotFoundComponent } from './not-found.component' import { By } from '@angular/platform-browser' import { LogoComponent } from '../common/logo/logo.component' +import { HttpClientTestingModule } from '@angular/common/http/testing' describe('NotFoundComponent', () => { let component: NotFoundComponent @@ -10,6 +11,7 @@ describe('NotFoundComponent', () => { beforeEach(async () => { TestBed.configureTestingModule({ declarations: [NotFoundComponent, LogoComponent], + imports: [HttpClientTestingModule], }).compileComponents() fixture = TestBed.createComponent(NotFoundComponent) diff --git a/src-ui/src/app/services/config.service.spec.ts b/src-ui/src/app/services/config.service.spec.ts index 3cfadb051..0675da844 100644 --- a/src-ui/src/app/services/config.service.spec.ts +++ b/src-ui/src/app/services/config.service.spec.ts @@ -39,4 +39,13 @@ describe('ConfigService', () => { ) expect(req.request.method).toEqual('PATCH') }) + + it('should support upload file with form data', () => { + service.uploadFile(new File([], 'test.png'), 1, 'app_logo').subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}config/1/` + ) + expect(req.request.method).toEqual('PATCH') + expect(req.request.body).not.toBeNull() + }) }) diff --git a/src-ui/src/app/services/config.service.ts b/src-ui/src/app/services/config.service.ts index 19158b3ce..5bc5f29c3 100644 --- a/src-ui/src/app/services/config.service.ts +++ b/src-ui/src/app/services/config.service.ts @@ -24,4 +24,16 @@ export class ConfigService { .patch(`${this.baseUrl}${config.id}/`, config) .pipe(first()) } + + uploadFile( + file: File, + configID: number, + configKey: string + ): Observable { + let formData = new FormData() + formData.append(configKey, file, file.name) + return this.http + .patch(`${this.baseUrl}${configID}/`, formData) + .pipe(first()) + } } diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss index c8e8e8d5c..0dc58403a 100644 --- a/src-ui/src/styles.scss +++ b/src-ui/src/styles.scss @@ -11,6 +11,9 @@ $grid-breakpoints: ( xxxl: 2400px ); +$form-file-button-bg: var(--bs-body-bg); +$form-file-button-hover-bg: var(--pngx-bg-alt); + @import "node_modules/bootstrap/scss/bootstrap"; @import "theme"; @import "~@ng-select/ng-select/themes/default.theme.css";