diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index e3cef8c95..684cf6d18 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -105,6 +105,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component' import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component' import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component' +import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component' import localeAf from '@angular/common/locales/af' import localeAr from '@angular/common/locales/ar' @@ -256,6 +257,7 @@ function initializeApp(settings: SettingsService) { CustomFieldsComponent, CustomFieldEditDialogComponent, CustomFieldsDropdownComponent, + ProfileEditDialogComponent, ], imports: [ BrowserModule, diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index a710600ef..556252670 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -39,6 +39,11 @@

Logged in as {{this.settingsService.displayName}}

+ diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts index 7b8bf4bce..152429358 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts @@ -9,7 +9,7 @@ import { fakeAsync, tick, } from '@angular/core/testing' -import { NgbModule } from '@ng-bootstrap/ng-bootstrap' +import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { BrowserModule } from '@angular/platform-browser' import { RouterTestingModule } from '@angular/router/testing' import { SettingsService } from 'src/app/services/settings.service' @@ -32,6 +32,7 @@ import { routes } from 'src/app/app-routing.module' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop' import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' +import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' const saved_views = [ { @@ -86,6 +87,7 @@ describe('AppFrameComponent', () => { let documentListViewService: DocumentListViewService let router: Router let savedViewSpy + let modalService: NgbModal beforeEach(async () => { TestBed.configureTestingModule({ @@ -98,6 +100,7 @@ describe('AppFrameComponent', () => { FormsModule, ReactiveFormsModule, DragDropModule, + NgbModalModule, ], providers: [ SettingsService, @@ -120,6 +123,7 @@ describe('AppFrameComponent', () => { ToastService, OpenDocumentsService, SearchService, + NgbModal, { provide: ActivatedRoute, useValue: { @@ -148,6 +152,7 @@ describe('AppFrameComponent', () => { openDocumentsService = TestBed.inject(OpenDocumentsService) searchService = TestBed.inject(SearchService) documentListViewService = TestBed.inject(DocumentListViewService) + modalService = TestBed.inject(NgbModal) router = TestBed.inject(Router) jest @@ -363,4 +368,12 @@ describe('AppFrameComponent', () => { >) expect(toastSpy).toHaveBeenCalled() }) + + it('should support edit profile', () => { + const modalSpy = jest.spyOn(modalService, 'open') + component.editProfile() + expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, { + backdrop: 'static', + }) + }) }) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.ts b/src-ui/src/app/components/app-frame/app-frame.component.ts index 0c8f149c1..f346dc089 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.ts @@ -39,6 +39,8 @@ import { CdkDragDrop, moveItemInArray, } from '@angular/cdk/drag-drop' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' @Component({ selector: 'pngx-app-frame', @@ -69,6 +71,7 @@ export class AppFrameComponent public settingsService: SettingsService, public tasksService: TasksService, private readonly toastService: ToastService, + private modalService: NgbModal, permissionsService: PermissionsService ) { super() @@ -121,6 +124,13 @@ export class AppFrameComponent this.isMenuCollapsed = true } + editProfile() { + this.modalService.open(ProfileEditDialogComponent, { + backdrop: 'static', + }) + this.closeMenu() + } + get openDocuments(): PaperlessDocument[] { return this.openDocumentsService.getOpenDocuments() } diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html new file mode 100644 index 000000000..18a04e376 --- /dev/null +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html @@ -0,0 +1,17 @@ +
+
+ + + diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.scss b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts new file mode 100644 index 000000000..63b17dc09 --- /dev/null +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts @@ -0,0 +1,89 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { ProfileEditDialogComponent } from './profile-edit-dialog.component' +import { ProfileService } from 'src/app/services/profile.service' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { + NgbActiveModal, + NgbModal, + NgbModalModule, + NgbModule, +} from '@ng-bootstrap/ng-bootstrap' +import { HttpClientModule } from '@angular/common/http' +import { TextComponent } from '../input/text/text.component' +import { PasswordComponent } from '../input/password/password.component' +import { of, throwError } from 'rxjs' +import { ToastService } from 'src/app/services/toast.service' + +const profile = { + email: 'foo@bar.com', + password: '*********', + first_name: 'foo', + last_name: 'bar', +} + +describe('ProfileEditDialogComponent', () => { + let component: ProfileEditDialogComponent + let fixture: ComponentFixture + let profileService: ProfileService + let toastService: ToastService + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + ProfileEditDialogComponent, + TextComponent, + PasswordComponent, + ], + providers: [NgbActiveModal], + imports: [ + HttpClientModule, + ReactiveFormsModule, + FormsModule, + NgbModalModule, + ], + }) + profileService = TestBed.inject(ProfileService) + toastService = TestBed.inject(ToastService) + fixture = TestBed.createComponent(ProfileEditDialogComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should get profile on init, display in form', () => { + const getSpy = jest.spyOn(profileService, 'get') + getSpy.mockReturnValue(of(profile)) + component.ngOnInit() + expect(getSpy).toHaveBeenCalled() + fixture.detectChanges() + expect(component.form.get('email').value).toEqual(profile.email) + }) + + it('should update profile on save, display error if needed', () => { + const newProfile = { + email: 'foo@bar2.com', + password: profile.password, + first_name: 'foo2', + last_name: profile.last_name, + } + const updateSpy = jest.spyOn(profileService, 'update') + const errorSpy = jest.spyOn(toastService, 'showError') + updateSpy.mockReturnValueOnce(throwError(() => new Error('failed to save'))) + component.save() + expect(errorSpy).toHaveBeenCalled() + + updateSpy.mockClear() + const infoSpy = jest.spyOn(toastService, 'showInfo') + component.form.patchValue(newProfile) + updateSpy.mockReturnValueOnce(of(newProfile)) + component.save() + expect(updateSpy).toHaveBeenCalledWith(newProfile) + expect(infoSpy).toHaveBeenCalled() + }) + + it('should close on cancel', () => { + const closeSpy = jest.spyOn(component.activeModal, 'close') + component.cancel() + expect(closeSpy).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts new file mode 100644 index 000000000..9422d35d2 --- /dev/null +++ b/src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts @@ -0,0 +1,51 @@ +import { Component, OnInit } from '@angular/core' +import { FormControl, FormGroup } from '@angular/forms' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { ProfileService } from 'src/app/services/profile.service' +import { ToastService } from 'src/app/services/toast.service' + +@Component({ + selector: 'pngx-profile-edit-dialog', + templateUrl: './profile-edit-dialog.component.html', + styleUrls: ['./profile-edit-dialog.component.scss'], +}) +export class ProfileEditDialogComponent implements OnInit { + public networkActive: boolean = false + public error: any + + public form = new FormGroup({ + email: new FormControl(''), + password: new FormControl(null), + first_name: new FormControl(''), + last_name: new FormControl(''), + }) + + constructor( + private profileService: ProfileService, + public activeModal: NgbActiveModal, + private toastService: ToastService + ) {} + + ngOnInit(): void { + this.profileService.get().subscribe((profile) => { + this.form.patchValue(profile) + }) + } + + save() { + const profile = Object.assign({}, this.form.value) + this.profileService.update(profile).subscribe({ + next: () => { + this.toastService.showInfo($localize`Profile updated successfully`) + this.activeModal.close() + }, + error: (error) => { + this.toastService.showError($localize`Error saving profile`, error) + }, + }) + } + + cancel() { + this.activeModal.close() + } +} diff --git a/src-ui/src/app/data/user-profile.ts b/src-ui/src/app/data/user-profile.ts new file mode 100644 index 000000000..fe9004526 --- /dev/null +++ b/src-ui/src/app/data/user-profile.ts @@ -0,0 +1,6 @@ +export interface PaperlessUserProfile { + email?: string + password?: string + first_name?: string + last_name?: string +} diff --git a/src-ui/src/app/services/profile.service.spec.ts b/src-ui/src/app/services/profile.service.spec.ts new file mode 100644 index 000000000..8e637bb12 --- /dev/null +++ b/src-ui/src/app/services/profile.service.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing' + +import { ProfileService } from './profile.service' +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing' +import { environment } from 'src/environments/environment' + +describe('ProfileService', () => { + let httpTestingController: HttpTestingController + let service: ProfileService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ProfileService], + imports: [HttpClientTestingModule], + }) + + httpTestingController = TestBed.inject(HttpTestingController) + service = TestBed.inject(ProfileService) + }) + + afterEach(() => { + httpTestingController.verify() + }) + + it('calls get profile endpoint', () => { + service.get().subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}profile/` + ) + expect(req.request.method).toEqual('GET') + }) + + it('calls patch on update', () => { + service.update({ email: 'foo@bar.com' }).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}profile/` + ) + expect(req.request.method).toEqual('PATCH') + expect(req.request.body).toEqual({ + email: 'foo@bar.com', + }) + }) +}) diff --git a/src-ui/src/app/services/profile.service.ts b/src-ui/src/app/services/profile.service.ts new file mode 100644 index 000000000..d52f4c7be --- /dev/null +++ b/src-ui/src/app/services/profile.service.ts @@ -0,0 +1,27 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { PaperlessUserProfile } from '../data/user-profile' +import { environment } from 'src/environments/environment' + +@Injectable({ + providedIn: 'root', +}) +export class ProfileService { + private endpoint = 'profile' + + constructor(private http: HttpClient) {} + + get(): Observable { + return this.http.get( + `${environment.apiBaseUrl}${this.endpoint}/` + ) + } + + update(profile: PaperlessUserProfile): Observable { + return this.http.patch( + `${environment.apiBaseUrl}${this.endpoint}/`, + profile + ) + } +}