mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge pull request #13 from shamoon/feature/unsaved-changes
Warnings for unsaved changes, 'intelligent' buttons when editing
This commit is contained in:
		
							
								
								
									
										15
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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}, | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
|     <div class="modal-header"> | ||||
|       <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|       <button type="button" class="close" aria-label="Close" (click)="cancelClicked()"> | ||||
|       <button type="button" class="close" aria-label="Close" (click)="cancel()"> | ||||
|         <span aria-hidden="true">×</span> | ||||
|       </button> | ||||
|     </div> | ||||
| @@ -9,8 +9,8 @@ | ||||
|       <p *ngIf="message">{{message}}</p> | ||||
|     </div> | ||||
|     <div class="modal-footer"> | ||||
|       <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button> | ||||
|       <button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|       <button type="button" class="btn btn-outline-dark" (click)="cancel()" [disabled]="!buttonsEnabled" i18n>Cancel</button> | ||||
|       <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|         <span *ngIf="!confirmButtonEnabled"> ({{seconds}})</span> | ||||
|       </button> | ||||
|   | ||||
| @@ -1,12 +1,13 @@ | ||||
| import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; | ||||
| import { Component, EventEmitter, Input, Output } from '@angular/core'; | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { Subject } from 'rxjs'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-confirm-dialog', | ||||
|   templateUrl: './confirm-dialog.component.html', | ||||
|   styleUrls: ['./confirm-dialog.component.scss'] | ||||
| }) | ||||
| export class ConfirmDialogComponent implements OnInit { | ||||
| export class ConfirmDialogComponent { | ||||
|  | ||||
|   constructor(public activeModal: NgbActiveModal) { } | ||||
|  | ||||
| @@ -34,6 +35,8 @@ export class ConfirmDialogComponent implements OnInit { | ||||
|   confirmButtonEnabled = true | ||||
|   seconds = 0 | ||||
|  | ||||
|   confirmSubject: Subject<boolean> | ||||
|  | ||||
|   delayConfirm(seconds: number) { | ||||
|     this.confirmButtonEnabled = false | ||||
|     this.seconds = seconds | ||||
| @@ -46,10 +49,15 @@ export class ConfirmDialogComponent implements OnInit { | ||||
|     }, 1000) | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
|  | ||||
|   cancelClicked() { | ||||
|   cancel() { | ||||
|     this.confirmSubject?.next(false) | ||||
|     this.confirmSubject?.complete() | ||||
|     this.activeModal.close() | ||||
|   } | ||||
|  | ||||
|   confirm() { | ||||
|     this.confirmClicked.emit() | ||||
|     this.confirmSubject?.next(true) | ||||
|     this.confirmSubject?.complete() | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -56,7 +56,7 @@ | ||||
|                     <a ngbNavLink i18n>Details</a> | ||||
|                     <ng-template ngbNavContent> | ||||
|  | ||||
|                         <app-input-text #inputTitle i18n-title title="Title" formControlName="title" [error]="error?.title"></app-input-text> | ||||
|                         <app-input-text #inputTitle i18n-title title="Title" formControlName="title" (keyup)="titleKeyUp($event)" [error]="error?.title"></app-input-text> | ||||
|                         <app-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></app-input-number> | ||||
|                         <app-input-date i18n-title title="Date created" formControlName="created" [error]="error?.created"></app-input-date> | ||||
|                         <app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" | ||||
| @@ -127,18 +127,26 @@ | ||||
|                 <li [ngbNavItem]="4" class="d-md-none"> | ||||
|                   <a ngbNavLink>Preview</a> | ||||
|                   <ng-template ngbNavContent *ngIf="pdfPreview.offsetParent == undefined"> | ||||
|                     <div class="pdf-viewer-container" *ngIf="getContentType() == 'application/pdf'"> | ||||
|                       <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true"></pdf-viewer> | ||||
|                     </div> | ||||
|                     <ng-container *ngIf="getContentType() == 'application/pdf'"> | ||||
|                         <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> | ||||
|                             <pdf-viewer [src]="previewUrl" [original-size]="false" [show-borders]="true" [show-all]="true" [render-text-mode]="2"></pdf-viewer> | ||||
|                         </div> | ||||
|                         <ng-template #nativePdfViewer> | ||||
|                             <object [data]="previewUrl | safe" class="preview-sticky" width="100%"></object> | ||||
|                         </ng-template> | ||||
|                     </ng-container> | ||||
|                     <ng-container *ngIf="getContentType() == 'text/plain'"> | ||||
|                         <object [data]="previewUrl | safe" type="text/plain" class="preview-sticky bg-white" width="100%"></object> | ||||
|                     </ng-container> | ||||
|                   </ng-template> | ||||
|                 </li> | ||||
|             </ul> | ||||
|  | ||||
|             <div [ngbNavOutlet]="nav" class="mt-2"></div> | ||||
|  | ||||
|             <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive">Discard</button>  | ||||
|             <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive">Save & next</button>  | ||||
|             <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>  | ||||
|             <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive || !(isDirty$ | async)">Discard</button>  | ||||
|             <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || !(isDirty$ | async)">Save & next</button>  | ||||
|             <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || !(isDirty$ | async)">Save</button>  | ||||
|         </form> | ||||
|     </div> | ||||
|  | ||||
| @@ -154,6 +162,5 @@ | ||||
|         <ng-container *ngIf="getContentType() == 'text/plain'"> | ||||
|             <object [data]="previewUrl | safe" type="text/plain" class="preview-sticky bg-white" width="100%"></object> | ||||
|         </ng-container> | ||||
|  | ||||
|     </div> | ||||
| </div> | ||||
|   | ||||
| @@ -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<string> = 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<any> | ||||
|   isDirty$: Observable<boolean> | ||||
|   unsubscribeNotifier: Subject<any> = 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() { | ||||
|   | ||||
| @@ -170,5 +170,5 @@ | ||||
|  | ||||
|   <div [ngbNavOutlet]="nav" class="border-left border-right border-bottom p-3 mb-3 shadow-sm"></div> | ||||
|  | ||||
|   <button type="submit" class="btn btn-primary" i18n>Save</button> | ||||
|   <button type="submit" class="btn btn-primary" [disabled]="!(isDirty$ | async)" i18n>Save</button> | ||||
| </form> | ||||
|   | ||||
| @@ -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<any> | ||||
|   storeSub: Subscription | ||||
|   isDirty$: Observable<boolean> | ||||
|  | ||||
|   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.`) | ||||
|   | ||||
							
								
								
									
										29
									
								
								src-ui/src/app/guards/dirty-form.guard.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src-ui/src/app/guards/dirty-form.guard.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<boolean> { | ||||
|     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<boolean>() | ||||
|     modal.componentInstance.confirmSubject = subject | ||||
|     return subject.asObservable() | ||||
|   } | ||||
| } | ||||
| @@ -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<number> = new Set<number>() | ||||
|  | ||||
|   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<boolean> { | ||||
|     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<boolean>() | ||||
|       modal.componentInstance.confirmSubject = subject | ||||
|       return subject.asObservable() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   closeAll() { | ||||
|     this.openDocuments.splice(0, this.openDocuments.length) | ||||
|     this.save() | ||||
|   closeAll(): Observable<boolean> { | ||||
|     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<boolean>() | ||||
|       modal.componentInstance.confirmSubject = subject | ||||
|       return subject.asObservable() | ||||
|     } else { | ||||
|       this.openDocuments.splice(0, this.openDocuments.length) | ||||
|       this.dirtyDocuments.clear() | ||||
|       this.save() | ||||
|       return of(true) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   save() { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon