Frontend upload of app logo

This commit is contained in:
shamoon 2024-01-12 01:20:02 -08:00
parent 60f37090d9
commit 3c8cde4e00
13 changed files with 225 additions and 1 deletions

View File

@ -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,

View File

@ -30,6 +30,7 @@
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
}
</div>
</div>

View File

@ -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<ConfigComponent>
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()
})
})

View File

@ -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
)
},
})
}
}

View File

@ -0,0 +1,30 @@
<div class="mb-3" [class.pb-3]="error">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
@if (title) {
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
}
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
<div class="input-group" [class.col-md-9]="horizontal" [class.is-invalid]="error">
<input #fileInput type="file" class="form-control" [id]="inputId" (change)="onFile($event)" [disabled]="disabled">
<button class="btn btn-outline-secondary py-0" type="button" (click)="onButton()" [disabled]="disabled || !file" i18n>Upload</button>
</div>
@if (filename) {
<small class="form-text text-muted">{{filename}}</small>
}
<input #inputField type="hidden" class="form-control small" [(ngModel)]="value" [disabled]="true">
@if (hint) {
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
}
<div class="invalid-feedback position-absolute top-100">
{{error}}
</div>
</div>
</div>

View File

@ -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<FileComponent>
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()
})
})

View File

@ -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<string> {
@Output()
upload = new EventEmitter<File>()
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
}
}

View File

@ -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<LogoComponent>
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')
})
})

View File

@ -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)

View File

@ -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()
})
})

View File

@ -24,4 +24,16 @@ export class ConfigService {
.patch<PaperlessConfig>(`${this.baseUrl}${config.id}/`, config)
.pipe(first())
}
uploadFile(
file: File,
configID: number,
configKey: string
): Observable<PaperlessConfig> {
let formData = new FormData()
formData.append(configKey, file, file.name)
return this.http
.patch<PaperlessConfig>(`${this.baseUrl}${configID}/`, formData)
.pipe(first())
}
}

View File

@ -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";