diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json index d17acc50e..e470a82b8 100644 --- a/src-ui/package-lock.json +++ b/src-ui/package-lock.json @@ -19,6 +19,7 @@ "@angular/router": "~11.2.14", "@ng-bootstrap/ng-bootstrap": "^9.1.2", "@ng-select/ng-select": "^7.0.0", + "@ngneat/dirty-check-forms": "^1.1.0", "bootstrap": "^4.5.0", "file-saver": "^2.0.5", "ng2-pdf-viewer": "^6.3.2", @@ -2427,6 +2428,14 @@ "@angular/forms": ">=11.0.0 <12.0.0" } }, + "node_modules/@ngneat/dirty-check-forms": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ngneat/dirty-check-forms/-/dirty-check-forms-1.1.0.tgz", + "integrity": "sha512-Ak6SUMUV2oFlaylhUnar1yT4ahmq3Y2mHrd9uQHesE0iUZWfQTrIN07kMtwyT2JXR/x4RqdAmvp/+IJ+QlUPGg==", + "peerDependencies": { + "tslib": "^1.10.0" + } + }, "node_modules/@ngtools/webpack": { "version": "11.2.14", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-11.2.14.tgz", @@ -18869,6 +18878,12 @@ "tslib": "^2.0.0" } }, + "@ngneat/dirty-check-forms": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ngneat/dirty-check-forms/-/dirty-check-forms-1.1.0.tgz", + "integrity": "sha512-Ak6SUMUV2oFlaylhUnar1yT4ahmq3Y2mHrd9uQHesE0iUZWfQTrIN07kMtwyT2JXR/x4RqdAmvp/+IJ+QlUPGg==", + "requires": {} + }, "@ngtools/webpack": { "version": "11.2.14", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-11.2.14.tgz", diff --git a/src-ui/package.json b/src-ui/package.json index b85368559..cd0d3f53b 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -22,6 +22,7 @@ "@angular/router": "~11.2.14", "@ng-bootstrap/ng-bootstrap": "^9.1.2", "@ng-select/ng-select": "^7.0.0", + "@ngneat/dirty-check-forms": "^1.1.0", "bootstrap": "^4.5.0", "file-saver": "^2.0.5", "ng2-pdf-viewer": "^6.3.2", diff --git a/src-ui/src/app/app-routing.module.ts b/src-ui/src/app/app-routing.module.ts index 031a4bb0b..c2ea53bb7 100644 --- a/src-ui/src/app/app-routing.module.ts +++ b/src-ui/src/app/app-routing.module.ts @@ -11,6 +11,7 @@ import { SettingsComponent } from './components/manage/settings/settings.compone import { TagListComponent } from './components/manage/tag-list/tag-list.component'; import { NotFoundComponent } from './components/not-found/not-found.component'; import {DocumentAsnComponent} from "./components/document-asn/document-asn.component"; +import { DirtyFormGuard } from './guards/dirty-form.guard'; const routes: Routes = [ {path: '', redirectTo: 'dashboard', pathMatch: 'full'}, @@ -19,13 +20,12 @@ const routes: Routes = [ {path: 'documents', component: DocumentListComponent }, {path: 'view/:id', component: DocumentListComponent }, {path: 'documents/:id', component: DocumentDetailComponent }, - {path: 'asn/:id', component: DocumentAsnComponent }, - + {path: 'asn/:id', component: DocumentAsnComponent }, {path: 'tags', component: TagListComponent }, {path: 'documenttypes', component: DocumentTypeListComponent }, {path: 'correspondents', component: CorrespondentListComponent }, {path: 'logs', component: LogsComponent }, - {path: 'settings', component: SettingsComponent }, + {path: 'settings', component: SettingsComponent, canDeactivate: [DirtyFormGuard] }, ]}, {path: '404', component: NotFoundComponent}, 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 f8e76f0ae..74f3cd5b5 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 @@ -1,8 +1,8 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, Params } from '@angular/router'; import { from, Observable, Subscription, BehaviorSubject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap, first } from 'rxjs/operators'; import { PaperlessDocument } from 'src/app/data/paperless-document'; import { OpenDocumentsService } from 'src/app/services/open-documents.service'; import { SavedViewService } from 'src/app/services/rest/saved-view.service'; @@ -18,7 +18,7 @@ import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'; templateUrl: './app-frame.component.html', styleUrls: ['./app-frame.component.scss'] }) -export class AppFrameComponent implements OnInit { +export class AppFrameComponent { constructor ( public router: Router, @@ -28,9 +28,7 @@ export class AppFrameComponent implements OnInit { public savedViewService: SavedViewService, private list: DocumentListViewService, private meta: Meta - ) { - - } + ) { } versionString = `${environment.appTitle} ${environment.version}` @@ -81,32 +79,36 @@ export class AppFrameComponent implements OnInit { } closeDocument(d: PaperlessDocument) { - this.closeMenu() - this.openDocumentsService.closeDocument(d) - - let route = this.activatedRoute.snapshot - while (route.firstChild) { - route = route.firstChild - } - if (route.component == DocumentDetailComponent && route.params['id'] == d.id) { - this.router.navigate([""]) - } + this.openDocumentsService.closeDocument(d).pipe(first()).subscribe(confirmed => { + if (confirmed) { + this.closeMenu() + let route = this.activatedRoute.snapshot + while (route.firstChild) { + route = route.firstChild + } + if (route.component == DocumentDetailComponent && route.params['id'] == d.id) { + this.router.navigate([""]) + } + } + }) } closeAll() { - this.closeMenu() - this.openDocumentsService.closeAll() + // user may need to confirm losing unsaved changes + this.openDocumentsService.closeAll().pipe(first()).subscribe(confirmed => { + if (confirmed) { + this.closeMenu() - let route = this.activatedRoute.snapshot - while (route.firstChild) { - route = route.firstChild - } - if (route.component == DocumentDetailComponent) { - this.router.navigate([""]) - } - } - - ngOnInit() { + // TODO: is there a better way to do this? + let route = this.activatedRoute + while (route.firstChild) { + route = route.firstChild + } + if (route.component === DocumentDetailComponent) { + this.router.navigate([""]) + } + } + }) } get displayName() { diff --git a/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.html index 8a8a297d9..780ae6891 100644 --- a/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.html +++ b/src-ui/src/app/components/common/confirm-dialog/confirm-dialog.component.html @@ -1,6 +1,6 @@ @@ -9,8 +9,8 @@

{{message}}

@@ -154,6 +162,5 @@ - diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index e711b3416..85b221307 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModal, NgbNav } from '@ng-bootstrap/ng-bootstrap'; @@ -19,6 +19,9 @@ import { PDFDocumentProxy } from 'ng2-pdf-viewer'; import { ToastService } from 'src/app/services/toast.service'; import { TextComponent } from '../common/input/text/text.component'; import { SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; +import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'; +import { Observable, Subject, BehaviorSubject } from 'rxjs'; +import { first, takeUntil, switchMap, map, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions'; import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'; @@ -27,7 +30,7 @@ import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'; templateUrl: './document-detail.component.html', styleUrls: ['./document-detail.component.scss'] }) -export class DocumentDetailComponent implements OnInit { +export class DocumentDetailComponent implements OnInit, OnDestroy, DirtyComponent { @ViewChild("inputTitle") titleInput: TextComponent @@ -45,6 +48,7 @@ export class DocumentDetailComponent implements OnInit { suggestions: PaperlessDocumentSuggestions title: string + titleSubject: Subject = new Subject() previewUrl: string downloadUrl: string downloadOriginalUrl: string @@ -65,11 +69,14 @@ export class DocumentDetailComponent implements OnInit { previewCurrentPage: number = 1 previewNumPages: number = 1 + store: BehaviorSubject + isDirty$: Observable + unsubscribeNotifier: Subject = new Subject() + @ViewChild('nav') nav: NgbNav @ViewChild('pdfPreview') set pdfPreview(element) { // this gets called when compontent added or removed from DOM - if (element && element.nativeElement.offsetParent !== null) { // its visible - + if (element && element.nativeElement.offsetParent !== null && this.nav?.activeId == 4) { // its visible setTimeout(()=> this.nav?.select(1)); } } @@ -85,7 +92,19 @@ export class DocumentDetailComponent implements OnInit { private documentListViewService: DocumentListViewService, private documentTitlePipe: DocumentTitlePipe, private toastService: ToastService, - private settings: SettingsService) { } + private settings: SettingsService) { + this.titleSubject.pipe( + debounceTime(200), + distinctUntilChanged(), + takeUntil(this.unsubscribeNotifier) + ).subscribe(titleValue => { + this.documentForm.patchValue({'title': titleValue}) + }) + } + + titleKeyUp(event) { + this.titleSubject.next(event.target?.value) + } get useNativePdfViewer(): boolean { return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER) @@ -96,15 +115,18 @@ export class DocumentDetailComponent implements OnInit { } ngOnInit(): void { - this.documentForm.valueChanges.subscribe(wow => { + this.documentForm.valueChanges.pipe(takeUntil(this.unsubscribeNotifier)).subscribe(wow => { Object.assign(this.document, this.documentForm.value) }) - this.correspondentService.listAll().subscribe(result => this.correspondents = result.results) - this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results) + this.correspondentService.listAll().pipe(first()).subscribe(result => this.correspondents = result.results) + this.documentTypeService.listAll().pipe(first()).subscribe(result => this.documentTypes = result.results) - this.route.paramMap.subscribe(paramMap => { - this.documentId = +paramMap.get('id') + this.route.paramMap.pipe(switchMap(paramMap => { + const documentId = +paramMap.get('id') + return this.documentsService.get(documentId) + })).pipe(switchMap((doc) => { + this.documentId = doc.id this.previewUrl = this.documentsService.getPreviewUrl(this.documentId) this.downloadUrl = this.documentsService.getDownloadUrl(this.documentId) this.downloadOriginalUrl = this.documentsService.getDownloadUrl(this.documentId, true) @@ -112,23 +134,44 @@ export class DocumentDetailComponent implements OnInit { if (this.openDocumentService.getOpenDocument(this.documentId)) { this.updateComponent(this.openDocumentService.getOpenDocument(this.documentId)) } else { - this.documentsService.get(this.documentId).subscribe(doc => { - this.openDocumentService.openDocument(doc) - this.updateComponent(doc) - }, error => {this.router.navigate(['404'])}) + this.openDocumentService.openDocument(doc) + this.updateComponent(doc) } - }) + // Initialize dirtyCheck + this.store = new BehaviorSubject({ + title: doc.title, + content: doc.content, + created: doc.created, + correspondent: doc.correspondent, + document_type: doc.document_type, + archive_serial_number: doc.archive_serial_number, + tags: doc.tags + }) + + this.isDirty$ = dirtyCheck(this.documentForm, this.store.asObservable()) + + return this.isDirty$.pipe(map(dirty => ({doc, dirty}))) + })) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(({doc, dirty}) => { + this.openDocumentService.setDirty(doc.id, dirty) + }, error => {this.router.navigate(['404'])}) + } + + ngOnDestroy() : void { + this.unsubscribeNotifier.next(); + this.unsubscribeNotifier.complete(); } updateComponent(doc: PaperlessDocument) { this.document = doc - this.documentsService.getMetadata(doc.id).subscribe(result => { + this.documentsService.getMetadata(doc.id).pipe(first()).subscribe(result => { this.metadata = result }, error => { this.metadata = null }) - this.documentsService.getSuggestions(doc.id).subscribe(result => { + this.documentsService.getSuggestions(doc.id).pipe(first()).subscribe(result => { this.suggestions = result }, error => { this.suggestions = null @@ -141,11 +184,13 @@ export class DocumentDetailComponent implements OnInit { var modal = this.modalService.open(DocumentTypeEditDialogComponent, {backdrop: 'static'}) modal.componentInstance.dialogMode = 'create' if (newName) modal.componentInstance.object = { name: newName } - modal.componentInstance.success.subscribe(newDocumentType => { - this.documentTypeService.listAll().subscribe(documentTypes => { - this.documentTypes = documentTypes.results - this.documentForm.get('document_type').setValue(newDocumentType.id) - }) + modal.componentInstance.success.pipe(switchMap(newDocumentType => { + return this.documentTypeService.listAll().pipe(map(documentTypes => ({newDocumentType, documentTypes}))) + })) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(({newDocumentType, documentTypes}) => { + this.documentTypes = documentTypes.results + this.documentForm.get('document_type').setValue(newDocumentType.id) }) } @@ -153,16 +198,18 @@ export class DocumentDetailComponent implements OnInit { var modal = this.modalService.open(CorrespondentEditDialogComponent, {backdrop: 'static'}) modal.componentInstance.dialogMode = 'create' if (newName) modal.componentInstance.object = { name: newName } - modal.componentInstance.success.subscribe(newCorrespondent => { - this.correspondentService.listAll().subscribe(correspondents => { - this.correspondents = correspondents.results - this.documentForm.get('correspondent').setValue(newCorrespondent.id) - }) + modal.componentInstance.success.pipe(switchMap(newCorrespondent => { + return this.correspondentService.listAll().pipe(map(correspondents => ({newCorrespondent, correspondents}))) + })) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(({newCorrespondent, correspondents}) => { + this.correspondents = correspondents.results + this.documentForm.get('correspondent').setValue(newCorrespondent.id) }) } discard() { - this.documentsService.get(this.documentId).subscribe(doc => { + this.documentsService.get(this.documentId).pipe(first()).subscribe(doc => { Object.assign(this.document, doc) this.title = doc.title this.documentForm.patchValue(doc) @@ -171,7 +218,8 @@ export class DocumentDetailComponent implements OnInit { save() { this.networkActive = true - this.documentsService.update(this.document).subscribe(result => { + this.store.next(this.documentForm.value) + this.documentsService.update(this.document).pipe(first()).subscribe(result => { this.close() this.networkActive = false this.error = null @@ -183,18 +231,20 @@ export class DocumentDetailComponent implements OnInit { saveEditNext() { this.networkActive = true - this.documentsService.update(this.document).subscribe(result => { + this.store.next(this.documentForm.value) + this.documentsService.update(this.document).pipe(switchMap(updateResult => { + return this.documentListViewService.getNext(this.documentId).pipe(map(nextDocId => ({nextDocId, updateResult}))) + })).pipe(switchMap(({nextDocId, updateResult}) => { + if (nextDocId && updateResult) return this.openDocumentService.closeDocument(this.document).pipe(map(closeResult => ({updateResult, nextDocId, closeResult}))) + })) + .pipe(first()) + .subscribe(({updateResult, nextDocId, closeResult}) => { this.error = null - this.documentListViewService.getNext(this.document.id).subscribe(nextDocId => { - this.networkActive = false - if (nextDocId) { - this.openDocumentService.closeDocument(this.document) - this.router.navigate(['documents', nextDocId]) - this.titleInput.focus() - } - }, error => { - this.networkActive = false - }) + this.networkActive = false + if (closeResult && updateResult && nextDocId) { + this.router.navigate(['documents', nextDocId]) + this.titleInput?.focus() + } }, error => { this.networkActive = false this.error = error.error @@ -202,12 +252,14 @@ export class DocumentDetailComponent implements OnInit { } close() { - this.openDocumentService.closeDocument(this.document) - if (this.documentListViewService.activeSavedViewId) { - this.router.navigate(['view', this.documentListViewService.activeSavedViewId]) - } else { - this.router.navigate(['documents']) - } + this.openDocumentService.closeDocument(this.document).pipe(first()).subscribe(closed => { + if (!closed) return; + if (this.documentListViewService.activeSavedViewId) { + this.router.navigate(['view', this.documentListViewService.activeSavedViewId]) + } else { + this.router.navigate(['documents']) + } + }) } delete() { @@ -217,17 +269,18 @@ export class DocumentDetailComponent implements OnInit { modal.componentInstance.message = $localize`The files for this document will be deleted permanently. This operation cannot be undone.` modal.componentInstance.btnClass = "btn-danger" modal.componentInstance.btnCaption = $localize`Delete document` - modal.componentInstance.confirmClicked.subscribe(() => { + modal.componentInstance.confirmClicked.pipe(switchMap(() => { modal.componentInstance.buttonsEnabled = false - this.documentsService.delete(this.document).subscribe(() => { - modal.close() - this.close() - }, error => { - this.toastService.showError($localize`Error deleting document: ${JSON.stringify(error)}`) - modal.componentInstance.buttonsEnabled = true - }) + return this.documentsService.delete(this.document) + })) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + modal.close() + this.close() + }, error => { + this.toastService.showError($localize`Error deleting document: ${JSON.stringify(error)}`) + modal.componentInstance.buttonsEnabled = true }) - } moreLike() { diff --git a/src-ui/src/app/components/manage/settings/settings.component.html b/src-ui/src/app/components/manage/settings/settings.component.html index c2a32906e..93a12faf4 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.html +++ b/src-ui/src/app/components/manage/settings/settings.component.html @@ -170,5 +170,5 @@
- + diff --git a/src-ui/src/app/components/manage/settings/settings.component.ts b/src-ui/src/app/components/manage/settings/settings.component.ts index ab71b1028..91dcc9809 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.ts @@ -1,40 +1,45 @@ -import { Component, Inject, LOCALE_ID, OnInit, Renderer2 } from '@angular/core'; +import { Component, Inject, LOCALE_ID, OnInit, OnDestroy, Renderer2 } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; import { DocumentListViewService } from 'src/app/services/document-list-view.service'; import { SavedViewService } from 'src/app/services/rest/saved-view.service'; import { LanguageOption, SettingsService, SETTINGS_KEYS } from 'src/app/services/settings.service'; import { ToastService } from 'src/app/services/toast.service'; +import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'; +import { Observable, Subscription, BehaviorSubject } from 'rxjs'; @Component({ selector: 'app-settings', templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'] }) -export class SettingsComponent implements OnInit { +export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { savedViewGroup = new FormGroup({}) settingsForm = new FormGroup({ - 'bulkEditConfirmationDialogs': new FormControl(this.settings.get(SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS)), - 'bulkEditApplyOnClose': new FormControl(this.settings.get(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE)), - 'documentListItemPerPage': new FormControl(this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)), - 'darkModeUseSystem': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM)), - 'darkModeEnabled': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED)), - 'darkModeInvertThumbs': new FormControl(this.settings.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)), - 'useNativePdfViewer': new FormControl(this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)), + 'bulkEditConfirmationDialogs': new FormControl(null), + 'bulkEditApplyOnClose': new FormControl(null), + 'documentListItemPerPage': new FormControl(null), + 'darkModeUseSystem': new FormControl(null), + 'darkModeEnabled': new FormControl(null), + 'useNativePdfViewer': new FormControl(null), 'savedViews': this.savedViewGroup, - 'displayLanguage': new FormControl(this.settings.getLanguage()), - 'dateLocale': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_LOCALE)), - 'dateFormat': new FormControl(this.settings.get(SETTINGS_KEYS.DATE_FORMAT)), - 'notificationsConsumerNewDocument': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)), - 'notificationsConsumerSuccess': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)), - 'notificationsConsumerFailed': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)), - 'notificationsConsumerSuppressOnDashboard': new FormControl(this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD)), + 'displayLanguage': new FormControl(null), + 'dateLocale': new FormControl(null), + 'dateFormat': new FormControl(null), + 'notificationsConsumerNewDocument': new FormControl(null), + 'notificationsConsumerSuccess': new FormControl(null), + 'notificationsConsumerFailed': new FormControl(null), + 'notificationsConsumerSuppressOnDashboard': new FormControl(null), }) savedViews: PaperlessSavedView[] + store: BehaviorSubject + storeSub: Subscription + isDirty$: Observable + get computedDateLocale(): string { return this.settingsForm.value.dateLocale || this.settingsForm.value.displayLanguage || this.currentLocale } @@ -50,17 +55,53 @@ export class SettingsComponent implements OnInit { ngOnInit() { this.savedViewService.listAll().subscribe(r => { this.savedViews = r.results + let storeData = { + 'bulkEditConfirmationDialogs': this.settings.get(SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS), + 'bulkEditApplyOnClose': this.settings.get(SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE), + 'documentListItemPerPage': this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE), + 'darkModeUseSystem': this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM), + 'darkModeEnabled': this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED), + 'useNativePdfViewer': this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER), + 'savedViews': {}, + 'displayLanguage': this.settings.getLanguage(), + 'dateLocale': this.settings.get(SETTINGS_KEYS.DATE_LOCALE), + 'dateFormat': this.settings.get(SETTINGS_KEYS.DATE_FORMAT), + 'notificationsConsumerNewDocument': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT), + 'notificationsConsumerSuccess': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS), + 'notificationsConsumerFailed': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED), + 'notificationsConsumerSuppressOnDashboard': this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD), + } + for (let view of this.savedViews) { + storeData.savedViews[view.id.toString()] = { + "id": view.id, + "name": view.name, + "show_on_dashboard": view.show_on_dashboard, + "show_in_sidebar": view.show_in_sidebar + } this.savedViewGroup.addControl(view.id.toString(), new FormGroup({ - "id": new FormControl(view.id), - "name": new FormControl(view.name), - "show_on_dashboard": new FormControl(view.show_on_dashboard), - "show_in_sidebar": new FormControl(view.show_in_sidebar) + "id": new FormControl(null), + "name": new FormControl(null), + "show_on_dashboard": new FormControl(null), + "show_in_sidebar": new FormControl(null) })) } + + this.store = new BehaviorSubject(storeData) + + this.storeSub = this.store.asObservable().subscribe(state => { + this.settingsForm.patchValue(state, { emitEvent: false }) + }) + + // Initialize dirtyCheck + this.isDirty$ = dirtyCheck(this.settingsForm, this.store.asObservable()) }) } + ngOnDestroy() { + this.storeSub && this.storeSub.unsubscribe(); + } + deleteSavedView(savedView: PaperlessSavedView) { this.savedViewService.delete(savedView).subscribe(() => { this.savedViewGroup.removeControl(savedView.id.toString()) @@ -84,6 +125,7 @@ export class SettingsComponent implements OnInit { this.settings.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED, this.settingsForm.value.notificationsConsumerFailed) this.settings.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD, this.settingsForm.value.notificationsConsumerSuppressOnDashboard) this.settings.setLanguage(this.settingsForm.value.displayLanguage) + this.store.next(this.settingsForm.value) this.documentListViewService.updatePageSize() this.settings.updateDarkModeSettings() this.toastService.showInfo($localize`Settings saved successfully.`) diff --git a/src-ui/src/app/guards/dirty-form.guard.ts b/src-ui/src/app/guards/dirty-form.guard.ts new file mode 100644 index 000000000..4e7bc39ea --- /dev/null +++ b/src-ui/src/app/guards/dirty-form.guard.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'; +import { Observable, Subject } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'; + +@Injectable({ providedIn: 'root' }) +export class DirtyFormGuard extends DirtyCheckGuard { + constructor(private modalService: NgbModal) { + super(); + } + + confirmChanges(): Observable { + let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) + modal.componentInstance.title = $localize`Unsaved Changes` + modal.componentInstance.messageBold = $localize`You have unsaved changes.` + modal.componentInstance.message = $localize`Are you sure you want to leave?` + modal.componentInstance.btnClass = "btn-warning" + modal.componentInstance.btnCaption = $localize`Leave page` + modal.componentInstance.confirmClicked.subscribe(() => { + modal.componentInstance.buttonsEnabled = false + modal.close() + }) + const subject = new Subject() + modal.componentInstance.confirmSubject = subject + return subject.asObservable() + } +} diff --git a/src-ui/src/app/services/open-documents.service.ts b/src-ui/src/app/services/open-documents.service.ts index 01f168aeb..92802c765 100644 --- a/src-ui/src/app/services/open-documents.service.ts +++ b/src-ui/src/app/services/open-documents.service.ts @@ -2,6 +2,10 @@ import { Injectable } from '@angular/core'; import { PaperlessDocument } from '../data/paperless-document'; import { OPEN_DOCUMENT_SERVICE } from '../data/storage-keys'; import { DocumentService } from './rest/document.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'; +import { Observable, Subject, of } from 'rxjs'; +import { first } from 'rxjs/operators'; @Injectable({ providedIn: 'root' @@ -10,7 +14,7 @@ export class OpenDocumentsService { private MAX_OPEN_DOCUMENTS = 5 - constructor(private documentService: DocumentService) { + constructor(private documentService: DocumentService, private modalService: NgbModal) { if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) { try { this.openDocuments = JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) @@ -22,6 +26,7 @@ export class OpenDocumentsService { } private openDocuments: PaperlessDocument[] = [] + private dirtyDocuments: Set = new Set() refreshDocument(id: number) { let index = this.openDocuments.findIndex(doc => doc.id == id) @@ -53,17 +58,62 @@ export class OpenDocumentsService { } } - closeDocument(doc: PaperlessDocument) { + setDirty(documentId: number, dirty: boolean) { + if (dirty) this.dirtyDocuments.add(documentId) + else this.dirtyDocuments.delete(documentId) + } + + closeDocument(doc: PaperlessDocument): Observable { let index = this.openDocuments.findIndex(d => d.id == doc.id) - if (index > -1) { + if (index == -1) return of(true); + if (!this.dirtyDocuments.has(doc.id)) { this.openDocuments.splice(index, 1) this.save() + return of(true) + } else { + let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) + modal.componentInstance.title = $localize`Unsaved Changes` + modal.componentInstance.messageBold = $localize`You have unsaved changes.` + modal.componentInstance.message = $localize`Are you sure you want to close this document?` + modal.componentInstance.btnClass = "btn-warning" + modal.componentInstance.btnCaption = $localize`Close document` + modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { + modal.componentInstance.buttonsEnabled = false + modal.close() + this.openDocuments.splice(index, 1) + this.dirtyDocuments.delete(doc.id) + this.save() + }) + const subject = new Subject() + modal.componentInstance.confirmSubject = subject + return subject.asObservable() } } - closeAll() { - this.openDocuments.splice(0, this.openDocuments.length) - this.save() + closeAll(): Observable { + if (this.dirtyDocuments.size) { + let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) + modal.componentInstance.title = $localize`Unsaved Changes` + modal.componentInstance.messageBold = $localize`You have unsaved changes.` + modal.componentInstance.message = $localize`Are you sure you want to close all documents?` + modal.componentInstance.btnClass = "btn-warning" + modal.componentInstance.btnCaption = $localize`Close documents` + modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { + modal.componentInstance.buttonsEnabled = false + modal.close() + this.openDocuments.splice(0, this.openDocuments.length) + this.dirtyDocuments.clear() + this.save() + }) + const subject = new Subject() + modal.componentInstance.confirmSubject = subject + return subject.asObservable() + } else { + this.openDocuments.splice(0, this.openDocuments.length) + this.dirtyDocuments.clear() + this.save() + return of(true) + } } save() {