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 9167dfc34..fcc07ec95 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -1,116 +1,41 @@ -
+ -

OCR Settings

- -
-
- Output Type -
-
- -
-
- -
-
- Pages -
-
- -
-
- -
-
- Mode -
-
- -
-
- -
-
- Skip Archive File -
-
- -
-
- -
-
- Image DPI -
-
- -
-
- -
-
- Clean -
-
- -
-
- -
-
- Deskew -
-
- -
-
- -
-
- Rotate Pages -
-
- -
-
- -
-
- Rotate Pages Threshold -
-
- -
-
- -
-
- Max Image Pixels -
-
- -
-
- -
-
- Color Conversion Strategy -
-
- -
-
- -
-
- OCR Arguments -
-
- -
-
- - -
+ +
+ + 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 e8de0237b..a625cc1b2 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 @@ -1,22 +1,96 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { ConfigComponent } from './config.component' +import { ConfigService } from 'src/app/services/config.service' +import { ToastService } from 'src/app/services/toast.service' +import { of, throwError } from 'rxjs' +import { OutputTypeConfig } from 'src/app/data/paperless-config' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { BrowserModule } from '@angular/platform-browser' +import { NgbModule } from '@ng-bootstrap/ng-bootstrap' +import { NgSelectModule } from '@ng-select/ng-select' +import { TextComponent } from '../../common/input/text/text.component' +import { NumberComponent } from '../../common/input/number/number.component' +import { CheckComponent } from '../../common/input/check/check.component' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { PageHeaderComponent } from '../../common/page-header/page-header.component' +import { SelectComponent } from '../../common/input/select/select.component' describe('ConfigComponent', () => { let component: ConfigComponent let fixture: ComponentFixture + let configService: ConfigService + let toastService: ToastService beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ConfigComponent], + declarations: [ + ConfigComponent, + TextComponent, + SelectComponent, + NumberComponent, + CheckComponent, + PageHeaderComponent, + ], + imports: [ + HttpClientTestingModule, + BrowserModule, + NgbModule, + NgSelectModule, + FormsModule, + ReactiveFormsModule, + ], }).compileComponents() + configService = TestBed.inject(ConfigService) + toastService = TestBed.inject(ToastService) fixture = TestBed.createComponent(ConfigComponent) component = fixture.componentInstance fixture.detectChanges() }) - it('should create', () => { - expect(component).toBeTruthy() + it('should load config on init, show error if necessary', () => { + const getSpy = jest.spyOn(configService, 'getConfig') + const errorSpy = jest.spyOn(toastService, 'showError') + getSpy.mockReturnValueOnce( + throwError(() => new Error('Error getting config')) + ) + component.ngOnInit() + expect(getSpy).toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalled() + getSpy.mockReturnValueOnce( + of({ output_type: OutputTypeConfig.PDF_A } as any) + ) + component.ngOnInit() + expect(component.initialConfig).toEqual({ + output_type: OutputTypeConfig.PDF_A, + }) + }) + + it('should save config, show error if necessary', () => { + const saveSpy = jest.spyOn(configService, 'saveConfig') + const errorSpy = jest.spyOn(toastService, 'showError') + saveSpy.mockReturnValueOnce( + throwError(() => new Error('Error saving config')) + ) + component.saveConfig() + expect(saveSpy).toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalled() + saveSpy.mockReturnValueOnce( + of({ output_type: OutputTypeConfig.PDF_A } as any) + ) + component.saveConfig() + expect(component.initialConfig).toEqual({ + output_type: OutputTypeConfig.PDF_A, + }) + }) + + it('should support discard changes', () => { + component.initialConfig = { output_type: OutputTypeConfig.PDF_A2 } as any + component.configForm.get('output_type').patchValue(OutputTypeConfig.PDF_A) + component.discardChanges() + expect(component.configForm.get('output_type').value).toEqual( + OutputTypeConfig.PDF_A2 + ) }) }) 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 9dc3d5338..f9bc823c2 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -5,14 +5,14 @@ import { Observable, Subject, Subscription, + first, takeUntil, } from 'rxjs' import { - ArchiveFileConfig, - CleanConfig, - ColorConvertConfig, - ModeConfig, - OutputTypeConfig, + PaperlessConfigOptions, + ConfigCategory, + ConfigOption, + ConfigOptionType, PaperlessConfig, } from 'src/app/data/paperless-config' import { ConfigService } from 'src/app/services/config.service' @@ -29,32 +29,36 @@ export class ConfigComponent extends ComponentWithPermissions implements OnInit, OnDestroy, DirtyComponent { - public ConfigChoices = { - output_type: Object.values(OutputTypeConfig), - mode: Object.values(ModeConfig), - skip_archive_file: Object.values(ArchiveFileConfig), - unpaper_clean: Object.values(CleanConfig), - color_conversion_strategy: Object.values(ColorConvertConfig), - } + public readonly ConfigOptionType = ConfigOptionType public configForm = new FormGroup({ - output_type: new FormControl(null), - pages: new FormControl(null), - language: new FormControl(null), - mode: new FormControl(null), - skip_archive_file: new FormControl(null), - image_dpi: new FormControl(null), - unpaper_clean: new FormControl(null), - deskew: new FormControl(null), - rotate_pages: new FormControl(null), - rotate_pages_threshold: new FormControl(null), - max_image_pixels: new FormControl(null), - color_conversion_strategy: new FormControl(null), - user_args: new FormControl(null), + id: new FormControl(), + output_type: new FormControl(), + pages: new FormControl(), + language: new FormControl(), + mode: new FormControl(), + skip_archive_file: new FormControl(), + image_dpi: new FormControl(), + unpaper_clean: new FormControl(), + deskew: new FormControl(), + rotate_pages: new FormControl(), + rotate_pages_threshold: new FormControl(), + max_image_pixels: new FormControl(), + color_conversion_strategy: new FormControl(), + user_args: new FormControl(), }) + get optionCategories(): string[] { + return Object.values(ConfigCategory) + } + + getCategoryOptions(category: string): ConfigOption[] { + return PaperlessConfigOptions.filter((o) => o.category === category) + } + public loading: boolean = false + initialConfig: PaperlessConfig store: BehaviorSubject storeSub: Subscription isDirty$: Observable @@ -69,14 +73,17 @@ export class ConfigComponent } ngOnInit(): void { + this.loading = true this.configService .getConfig() .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe({ next: (config) => { + this.loading = false this.initialize(config) }, error: (e) => { + this.loading = false this.toastService.showError($localize`Error retrieving config`, e) }, }) @@ -88,21 +95,51 @@ export class ConfigComponent } private initialize(config: PaperlessConfig) { - this.store = new BehaviorSubject(config) + if (!this.store) { + this.store = new BehaviorSubject(config) - this.store - .asObservable() - .pipe(takeUntil(this.unsubscribeNotifier)) - .subscribe((state) => { - this.configForm.patchValue(state, { emitEvent: false }) - }) + this.store + .asObservable() + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe((state) => { + this.configForm.patchValue(state, { emitEvent: false }) + }) - this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable()) + this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable()) + } this.configForm.patchValue(config) + + this.initialConfig = config + } + + getDocsUrl(key: string) { + return `https://docs.paperless-ngx.com/configuration/#${key}` } public saveConfig() { - throw Error('Not Implemented') + this.loading = true + this.configService + .saveConfig(this.configForm.value as PaperlessConfig) + .pipe(takeUntil(this.unsubscribeNotifier), first()) + .subscribe({ + next: (config) => { + this.loading = false + this.initialize(config) + this.store.next(config) + this.toastService.showInfo($localize`Configuration updated`) + }, + error: (e) => { + this.loading = false + this.toastService.showError( + $localize`An error occurred updating configuration`, + e + ) + }, + }) + } + + public discardChanges() { + this.configForm.reset(this.initialConfig) } } diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index bd251d05c..5720235e3 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -37,6 +37,127 @@ export enum ColorConvertConfig { CMYK = 'CMYK', } +export enum ConfigOptionType { + String = 'string', + Number = 'number', + Select = 'select', + Boolean = 'boolean', +} + +export const ConfigCategory = { + OCR: $localize`OCR Settings`, +} + +export interface ConfigOption { + key: string + title: string + type: ConfigOptionType + choices?: Array + config_key?: string + category: string +} + +function mapToFlatChoices(enumObj: Object): Array { + return Object.keys(enumObj).map((key) => { + return { + id: enumObj[key], + name: enumObj[key], + } + }) +} + +export const PaperlessConfigOptions: ConfigOption[] = [ + { + key: 'output_type', + title: $localize`Output Type`, + type: ConfigOptionType.Select, + choices: mapToFlatChoices(OutputTypeConfig), + config_key: 'PAPERLESS_OCR_OUTPUT_TYPE', + category: ConfigCategory.OCR, + }, + { + key: 'pages', + title: $localize`Pages`, + type: ConfigOptionType.Number, + config_key: 'PAPERLESS_OCR_PAGES', + category: ConfigCategory.OCR, + }, + { + key: 'mode', + title: $localize`Mode`, + type: ConfigOptionType.Select, + choices: mapToFlatChoices(ModeConfig), + config_key: 'PAPERLESS_OCR_MODE', + category: ConfigCategory.OCR, + }, + { + key: 'skip_archive_file', + title: $localize`Skip Archive File`, + type: ConfigOptionType.Select, + choices: mapToFlatChoices(ArchiveFileConfig), + config_key: 'PAPERLESS_OCR_SKIP_ARCHIVE_FILE', + category: ConfigCategory.OCR, + }, + { + key: 'image_dpi', + title: $localize`Image DPI`, + type: ConfigOptionType.Number, + config_key: 'PAPERLESS_OCR_IMAGE_DPI', + category: ConfigCategory.OCR, + }, + { + key: 'unpaper_clean', + title: $localize`Clean`, + type: ConfigOptionType.Select, + choices: mapToFlatChoices(CleanConfig), + config_key: 'PAPERLESS_OCR_CLEAN', + category: ConfigCategory.OCR, + }, + { + key: 'deskew', + title: $localize`Deskew`, + type: ConfigOptionType.Boolean, + config_key: 'PAPERLESS_OCR_DESKEW', + category: ConfigCategory.OCR, + }, + { + key: 'rotate_pages', + title: $localize`Rotate Pages`, + type: ConfigOptionType.Boolean, + config_key: 'PAPERLESS_OCR_ROTATE_PAGES', + category: ConfigCategory.OCR, + }, + { + key: 'rotate_pages_threshold', + title: $localize`Rotate Pages Threshold`, + type: ConfigOptionType.Number, + config_key: 'PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD', + category: ConfigCategory.OCR, + }, + { + key: 'max_image_pixels', + title: $localize`Max Image Pixels`, + type: ConfigOptionType.Number, + config_key: 'PAPERLESS_OCR_IMAGE_DPI', + category: ConfigCategory.OCR, + }, + { + key: 'color_conversion_strategy', + title: $localize`Color Conversion Strategy`, + type: ConfigOptionType.Select, + choices: mapToFlatChoices(ColorConvertConfig), + config_key: 'PAPERLESS_OCR_COLOR_CONVERSION_STRATEGY', + category: ConfigCategory.OCR, + }, + { + key: 'user_args', + title: $localize`OCR Arguments`, + type: ConfigOptionType.String, + config_key: 'PAPERLESS_OCR_USER_ARGS', + category: ConfigCategory.OCR, + }, +] + export interface PaperlessConfig extends ObjectWithId { output_type: OutputTypeConfig pages: number diff --git a/src-ui/src/app/services/config.service.spec.ts b/src-ui/src/app/services/config.service.spec.ts index 0c1357ff6..3cfadb051 100644 --- a/src-ui/src/app/services/config.service.spec.ts +++ b/src-ui/src/app/services/config.service.spec.ts @@ -1,16 +1,42 @@ import { TestBed } from '@angular/core/testing' import { ConfigService } from './config.service' +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing' +import { environment } from 'src/environments/environment' +import { OutputTypeConfig, PaperlessConfig } from '../data/paperless-config' describe('ConfigService', () => { let service: ConfigService + let httpTestingController: HttpTestingController beforeEach(() => { - TestBed.configureTestingModule({}) + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }) service = TestBed.inject(ConfigService) + httpTestingController = TestBed.inject(HttpTestingController) }) - it('should be created', () => { - expect(service).toBeTruthy() + it('should call correct API endpoint on get config', () => { + service.getConfig().subscribe() + httpTestingController + .expectOne(`${environment.apiBaseUrl}config/`) + .flush([{}]) + }) + + it('should call correct API endpoint on set config', () => { + service + .saveConfig({ + id: 1, + output_type: OutputTypeConfig.PDF_A, + } as PaperlessConfig) + .subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}config/1/` + ) + expect(req.request.method).toEqual('PATCH') }) }) diff --git a/src-ui/src/app/services/config.service.ts b/src-ui/src/app/services/config.service.ts index 8f8b84ac5..19158b3ce 100644 --- a/src-ui/src/app/services/config.service.ts +++ b/src-ui/src/app/services/config.service.ts @@ -20,6 +20,8 @@ export class ConfigService { } saveConfig(config: PaperlessConfig): Observable { - return this.http.patch(this.baseUrl, config).pipe(first()) + return this.http + .patch(`${this.baseUrl}${config.id}/`, config) + .pipe(first()) } }