mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-16 00:36:22 +00:00
Enhancement: settings reorganization & improvements, separate admin section (#4251)
* Separate admin / manage sections * Move mail settings to its own component * Move users and groups to its own component * Move default permissions to its own settings tab * Unify list styling, add tour step, refactor components * Only patch saved views that have changed on settings save * Update messages.xlf * Remove unused methods in settings.component.ts * Drop admin section to bottom of sidebar, cleanup outdated, add docs link to dropdown * Better visually unify management list & other list pages
This commit is contained in:
25
src-ui/src/app/components/admin/logs/logs.component.html
Normal file
25
src-ui/src/app/components/admin/logs/logs.component.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<pngx-page-header title="Logs" i18n-title>
|
||||
|
||||
</pngx-page-header>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
|
||||
<li *ngFor="let logFile of logFiles" [ngbNavItem]="logFile">
|
||||
<a ngbNavLink>{{logFile}}.log</a>
|
||||
</li>
|
||||
<div *ngIf="isLoading && !logFiles.length" class="pb-2">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||
|
||||
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
||||
<div *ngIf="isLoading && logFiles.length">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</div>
|
||||
<p
|
||||
class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
|
||||
*ngFor="let log of logs">{{log}}</p>
|
||||
</div>
|
26
src-ui/src/app/components/admin/logs/logs.component.scss
Normal file
26
src-ui/src/app/components/admin/logs/logs.component.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
.log-entry-10 {
|
||||
color: lightslategray !important;
|
||||
}
|
||||
|
||||
.log-entry-30 {
|
||||
color: yellow !important;
|
||||
}
|
||||
|
||||
.log-entry-40 {
|
||||
color: red !important;
|
||||
}
|
||||
|
||||
.log-entry-50 {
|
||||
color: lightcoral !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
overflow-y: scroll;
|
||||
height: calc(100vh - 200px);
|
||||
top: 70px;
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
71
src-ui/src/app/components/admin/logs/logs.component.spec.ts
Normal file
71
src-ui/src/app/components/admin/logs/logs.component.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { LogService } from 'src/app/services/rest/log.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LogsComponent } from './logs.component'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { BrowserModule, By } from '@angular/platform-browser'
|
||||
|
||||
const paperless_logs = [
|
||||
'[2023-05-29 03:05:01,224] [DEBUG] [paperless.tasks] Training data unchanged.',
|
||||
'[2023-05-29 04:05:00,622] [DEBUG] [paperless.classifier] Gathering data from database...',
|
||||
'[2023-05-29 04:05:01,213] [DEBUG] [paperless.tasks] Training data unchanged.',
|
||||
'[2023-06-11 00:30:01,774] [INFO] [paperless.sanity_checker] Document contains no OCR data',
|
||||
'[2023-06-11 00:30:01,774] [WARNING] [paperless.sanity_checker] Made up',
|
||||
'[2023-06-11 00:30:01,774] [ERROR] [paperless.sanity_checker] Document contains no OCR data',
|
||||
'[2023-06-11 00:30:01,774] [CRITICAL] [paperless.sanity_checker] Document contains no OCR data',
|
||||
]
|
||||
const mail_logs = [
|
||||
'[2023-06-09 01:10:00,666] [DEBUG] [paperless_mail] Rule inbox@example.com.Incoming: Searching folder with criteria (SINCE 10-May-2023 UNSEEN)',
|
||||
'[2023-06-09 01:10:01,385] [DEBUG] [paperless_mail] Rule inbox@example.com.Incoming: Processed 3 matching mail(s)',
|
||||
]
|
||||
|
||||
describe('LogsComponent', () => {
|
||||
let component: LogsComponent
|
||||
let fixture: ComponentFixture<LogsComponent>
|
||||
let logService: LogService
|
||||
let logSpy
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [LogsComponent, PageHeaderComponent],
|
||||
providers: [],
|
||||
imports: [HttpClientTestingModule, BrowserModule, NgbModule],
|
||||
}).compileComponents()
|
||||
|
||||
logService = TestBed.inject(LogService)
|
||||
jest.spyOn(logService, 'list').mockReturnValue(of(['paperless', 'mail']))
|
||||
logSpy = jest.spyOn(logService, 'get')
|
||||
logSpy.mockImplementation((id) => {
|
||||
return of(id === 'paperless' ? paperless_logs : mail_logs)
|
||||
})
|
||||
fixture = TestBed.createComponent(LogsComponent)
|
||||
component = fixture.componentInstance
|
||||
window.HTMLElement.prototype.scroll = function () {} // mock scroll
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should display logs with first log initially', () => {
|
||||
expect(logSpy).toHaveBeenCalledWith('paperless')
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||
paperless_logs[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('should load log when tab clicked', () => {
|
||||
fixture.debugElement
|
||||
.queryAll(By.directive(NgbNavLink))[1]
|
||||
.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(logSpy).toHaveBeenCalledWith('mail')
|
||||
})
|
||||
|
||||
it('should handle error with no logs', () => {
|
||||
logSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('error getting logs'))
|
||||
)
|
||||
component.reloadLogs()
|
||||
expect(component.logs).toHaveLength(0)
|
||||
})
|
||||
})
|
94
src-ui/src/app/components/admin/logs/logs.component.ts
Normal file
94
src-ui/src/app/components/admin/logs/logs.component.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
OnInit,
|
||||
AfterViewChecked,
|
||||
ViewChild,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { LogService } from 'src/app/services/rest/log.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-logs',
|
||||
templateUrl: './logs.component.html',
|
||||
styleUrls: ['./logs.component.scss'],
|
||||
})
|
||||
export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
public logs: string[] = []
|
||||
|
||||
public logFiles: string[] = []
|
||||
|
||||
public activeLog: string
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
public isLoading: boolean = false
|
||||
|
||||
@ViewChild('logContainer') logContainer: ElementRef
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isLoading = true
|
||||
this.logService
|
||||
.list()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => {
|
||||
this.logFiles = result
|
||||
this.isLoading = false
|
||||
if (this.logFiles.length > 0) {
|
||||
this.activeLog = this.logFiles[0]
|
||||
this.reloadLogs()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
this.scrollToBottom()
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(true)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
reloadLogs() {
|
||||
this.isLoading = true
|
||||
this.logService
|
||||
.get(this.activeLog)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.logs = result
|
||||
this.isLoading = false
|
||||
},
|
||||
error: () => {
|
||||
this.logs = []
|
||||
this.isLoading = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
getLogLevel(log: string) {
|
||||
if (log.indexOf('[DEBUG]') != -1) {
|
||||
return 10
|
||||
} else if (log.indexOf('[WARNING]') != -1) {
|
||||
return 30
|
||||
} else if (log.indexOf('[ERROR]') != -1) {
|
||||
return 40
|
||||
} else if (log.indexOf('[CRITICAL]') != -1) {
|
||||
return 50
|
||||
} else {
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom(): void {
|
||||
this.logContainer?.nativeElement.scroll({
|
||||
top: this.logContainer.nativeElement.scrollHeight,
|
||||
left: 0,
|
||||
behavior: 'auto',
|
||||
})
|
||||
}
|
||||
}
|
324
src-ui/src/app/components/admin/settings/settings.component.html
Normal file
324
src-ui/src/app/components/admin/settings/settings.component.html
Normal file
@@ -0,0 +1,324 @@
|
||||
<pngx-page-header title="Settings" i18n-title>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
|
||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
|
||||
<ng-container i18n>Open Django Admin</ng-container>
|
||||
<svg class="sidebaricon ms-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/>
|
||||
</svg>
|
||||
</a>
|
||||
</pngx-page-header>
|
||||
|
||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||
|
||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
|
||||
<li [ngbNavItem]="SettingsNavIDs.General">
|
||||
<a ngbNavLink i18n>General</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<h4 i18n>Appearance</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Display language</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<select class="form-select" formControlName="displayLanguage">
|
||||
<option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale !== 'en-US'"> - {{lang.englishName}}</span></option>
|
||||
</select>
|
||||
|
||||
<small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Date display</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<select class="form-select" formControlName="dateLocale">
|
||||
<option *ngFor="let lang of dateLocaleOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code"> - {{today | customDate:'shortDate':null:lang.code}}</span></option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Date format</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<div class="form-check">
|
||||
<input type="radio" id="dateFormatShort" name="dateFormat" class="form-check-input" formControlName="dateFormat" value="shortDate">
|
||||
<label class="form-check-label" for="dateFormatShort" i18n>Short: {{today | customDate:'shortDate':null:computedDateLocale}}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="radio" id="dateFormatMedium" name="dateFormat" class="form-check-input" formControlName="dateFormat" value="mediumDate">
|
||||
<label class="form-check-label" for="dateFormatMedium" i18n>Medium: {{today | customDate:'mediumDate':null:computedDateLocale}}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="radio" id="dateFormatLong" name="dateFormat" class="form-check-input" formControlName="dateFormat" value="longDate">
|
||||
<label class="form-check-label" for="dateFormatLong" i18n>Long: {{today | customDate:'longDate':null:computedDateLocale}}</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Items per page</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<select class="form-select" formControlName="documentListItemPerPage">
|
||||
<option [ngValue]="10">10</option>
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Document editor</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Sidebar</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Dark mode</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Use system settings" formControlName="darkModeUseSystem"></pngx-input-check>
|
||||
<pngx-input-check [hidden]="settingsForm.value.darkModeUseSystem" i18n-title title="Enable dark mode" formControlName="darkModeEnabled"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Invert thumbnails in dark mode" formControlName="darkModeInvertThumbs"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Theme Color</span>
|
||||
</div>
|
||||
<div class="col col-md-3">
|
||||
<pngx-input-color i18n-title formControlName="themeColor" [error]="error?.color"></pngx-input-color>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-link btn-sm pt-2 ps-0" [disabled]="!this.settingsForm.get('themeColor').value" (click)="clearThemeColor()">
|
||||
<svg fill="currentColor" class="buttonicon-sm me-1">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" id="update-checking" i18n>Update checking</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<p i18n>
|
||||
Update checking works by pinging the public <a href="https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest" target="_blank" rel="noopener noreferrer">GitHub API</a> for the latest release to determine whether a new version is available.<br/>
|
||||
Actual updating of the app must still be performed manually.
|
||||
</p>
|
||||
<p i18n>
|
||||
<em>No tracking data is collected by the app in any way.</em>
|
||||
</p>
|
||||
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Bulk editing</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs" i18n-hint hint="Deleting documents will always ask for confirmation."></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Notes</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="SettingsNavIDs.Permissions">
|
||||
<a ngbNavLink i18n>Permissions</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<h4 i18n>Default Permissions</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<p i18n>
|
||||
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Default Owner</span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<pngx-input-select [items]="users" bindLabel="username" formControlName="defaultPermsOwner" [allowNull]="true"></pngx-input-select>
|
||||
<small class="form-text text-muted text-end d-block mt-n2" i18n>Objects without an owner can be viewed and edited by all users</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Default View Permissions</span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<span class="d-block pt-1" i18n>Users:</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||
<pngx-permissions-user type="view" formControlName="defaultPermsViewUsers"></pngx-permissions-user>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<span class="d-block pt-1" i18n>Groups:</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Group }">
|
||||
<pngx-permissions-group type="view" formControlName="defaultPermsViewGroups"></pngx-permissions-group>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3 col-form-label pt-0">
|
||||
<span i18n>Default Edit Permissions</span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<span class="d-block pt-1" i18n>Users:</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||
<pngx-permissions-user type="view" formControlName="defaultPermsEditUsers"></pngx-permissions-user>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<span class="d-block pt-1" i18n>Groups:</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Group }">
|
||||
<pngx-permissions-group type="view" formControlName="defaultPermsEditGroups"></pngx-permissions-group>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="SettingsNavIDs.Notifications">
|
||||
<a ngbNavLink i18n>Notifications</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<h4 i18n>Document processing</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="SettingsNavIDs.SavedViews">
|
||||
<a ngbNavLink i18n>Saved views</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<h4 i18n>Settings</h4>
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 i18n>Views</h4>
|
||||
<div formGroupName="savedViews">
|
||||
|
||||
<div *ngFor="let view of savedViews" [formGroupName]="view.id" class="row">
|
||||
<div class="mb-3 col">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Name</label>
|
||||
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
|
||||
</div>
|
||||
|
||||
<div class="mb-2 col">
|
||||
<label class="form-label" for="show_on_dashboard_{{view.id}}" i18n> <span class="visually-hidden">Appears on</span></label>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" i18n>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="savedViews && savedViews.length === 0" i18n>No saved views defined.</div>
|
||||
|
||||
<div *ngIf="!savedViews">
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary mb-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||
</form>
|
@@ -0,0 +1,356 @@
|
||||
import { ViewportScroller, DatePipe } from '@angular/common'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router, ActivatedRoute, convertToParamMap } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModule,
|
||||
NgbAlertModule,
|
||||
NgbNavLink,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService, Toast } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
import { ColorComponent } from '../../common/input/color/color.component'
|
||||
import { NumberComponent } from '../../common/input/number/number.component'
|
||||
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
||||
import { SelectComponent } from '../../common/input/select/select.component'
|
||||
import { TagsComponent } from '../../common/input/tags/tags.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { SettingsComponent } from './settings.component'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
|
||||
const savedViews = [
|
||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||
{ id: 2, name: 'view2', show_in_sidebar: false, show_on_dashboard: false },
|
||||
]
|
||||
const users = [
|
||||
{ id: 1, username: 'user1', is_superuser: false },
|
||||
{ id: 2, username: 'user2', is_superuser: false },
|
||||
]
|
||||
const groups = [
|
||||
{ id: 1, name: 'group1' },
|
||||
{ id: 2, name: 'group2' },
|
||||
]
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
let component: SettingsComponent
|
||||
let fixture: ComponentFixture<SettingsComponent>
|
||||
let router: Router
|
||||
let settingsService: SettingsService
|
||||
let savedViewService: SavedViewService
|
||||
let activatedRoute: ActivatedRoute
|
||||
let viewportScroller: ViewportScroller
|
||||
let toastService: ToastService
|
||||
let userService: UserService
|
||||
let permissionsService: PermissionsService
|
||||
let groupService: GroupService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
SettingsComponent,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
ConfirmDialogComponent,
|
||||
CheckComponent,
|
||||
ColorComponent,
|
||||
SafeHtmlPipe,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
TagsComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
IfOwnerDirective,
|
||||
],
|
||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||
imports: [
|
||||
NgbModule,
|
||||
HttpClientTestingModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbAlertModule,
|
||||
NgSelectModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
router = TestBed.inject(Router)
|
||||
activatedRoute = TestBed.inject(ActivatedRoute)
|
||||
viewportScroller = TestBed.inject(ViewportScroller)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = users[0]
|
||||
userService = TestBed.inject(UserService)
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserOwnsObject')
|
||||
.mockReturnValue(true)
|
||||
groupService = TestBed.inject(GroupService)
|
||||
savedViewService = TestBed.inject(SavedViewService)
|
||||
})
|
||||
|
||||
function completeSetup(excludeService = null) {
|
||||
if (excludeService !== userService) {
|
||||
jest.spyOn(userService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: users.map((u) => u.id),
|
||||
count: users.length,
|
||||
results: users.concat([]),
|
||||
})
|
||||
)
|
||||
}
|
||||
if (excludeService !== groupService) {
|
||||
jest.spyOn(groupService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: groups.map((g) => g.id),
|
||||
count: groups.length,
|
||||
results: groups.concat([]),
|
||||
})
|
||||
)
|
||||
}
|
||||
if (excludeService !== savedViewService) {
|
||||
jest.spyOn(savedViewService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: savedViews.map((v) => v.id),
|
||||
count: savedViews.length,
|
||||
results: (savedViews as PaperlessSavedView[]).concat([]),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fixture = TestBed.createComponent(SettingsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
}
|
||||
|
||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => {
|
||||
completeSetup()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
||||
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
||||
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
|
||||
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'savedviews'])
|
||||
|
||||
const initSpy = jest.spyOn(component, 'initialize')
|
||||
component.isDirty = true // mock dirty
|
||||
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
|
||||
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
|
||||
expect(initSpy).not.toHaveBeenCalled()
|
||||
|
||||
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
|
||||
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
|
||||
expect(initSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support direct link to tab by URL, scroll if needed', () => {
|
||||
completeSetup()
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap({ section: 'notifications' })))
|
||||
activatedRoute.snapshot.fragment = '#notifications'
|
||||
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
||||
component.ngOnInit()
|
||||
expect(component.activeNavID).toEqual(3) // Users & Groups
|
||||
component.ngAfterViewInit()
|
||||
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
||||
})
|
||||
|
||||
it('should support save saved views, show error', () => {
|
||||
completeSetup()
|
||||
|
||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
||||
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
|
||||
// saved views error first
|
||||
savedViewPatchSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('unable to save saved views'))
|
||||
)
|
||||
component.saveSettings()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
toastSpy.mockClear()
|
||||
toastErrorSpy.mockClear()
|
||||
savedViewPatchSpy.mockClear()
|
||||
|
||||
// succeed saved views
|
||||
savedViewPatchSpy.mockReturnValueOnce(
|
||||
of(savedViews as PaperlessSavedView[])
|
||||
)
|
||||
component.saveSettings()
|
||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update only patch saved views that have changed', () => {
|
||||
completeSetup()
|
||||
|
||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
||||
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
|
||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
component.saveSettings()
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
|
||||
const view = savedViews[0]
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
// register change
|
||||
component.savedViewGroup.get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
] = !view.show_on_dashboard
|
||||
fixture.detectChanges()
|
||||
|
||||
component.saveSettings()
|
||||
expect(patchSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
show_in_sidebar: view.show_in_sidebar,
|
||||
show_on_dashboard: !view.show_on_dashboard,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||
completeSetup()
|
||||
jest.spyOn(savedViewService, 'patchMany').mockReturnValue(of([]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||
const appearanceSettingsSpy = jest.spyOn(
|
||||
settingsService,
|
||||
'updateAppearanceSettings'
|
||||
)
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
|
||||
// error first
|
||||
storeSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('unable to save settings'))
|
||||
)
|
||||
component.saveSettings()
|
||||
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(24)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
component.saveSettings()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should offer reload if settings changes require', () => {
|
||||
completeSetup()
|
||||
jest.spyOn(savedViewService, 'patchMany').mockReturnValue(of([]))
|
||||
let toast: Toast
|
||||
toastService.getToasts().subscribe((t) => (toast = t[0]))
|
||||
component.initialize(true) // reset
|
||||
component.store.getValue()['displayLanguage'] = 'en-US'
|
||||
component.store.getValue()['updateCheckingEnabled'] = false
|
||||
component.settingsForm.value.displayLanguage = 'en-GB'
|
||||
component.settingsForm.value.updateCheckingEnabled = true
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true))
|
||||
component.saveSettings()
|
||||
expect(toast.actionName).toEqual('Reload now')
|
||||
})
|
||||
|
||||
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
||||
completeSetup()
|
||||
const appearanceSpy = jest.spyOn(
|
||||
settingsService,
|
||||
'updateAppearanceSettings'
|
||||
)
|
||||
const colorInput = fixture.debugElement.query(By.directive(ColorComponent))
|
||||
colorInput.query(By.css('input')).nativeElement.value = '#ff0000'
|
||||
colorInput
|
||||
.query(By.css('input'))
|
||||
.nativeElement.dispatchEvent(new Event('change'))
|
||||
fixture.detectChanges()
|
||||
expect(appearanceSpy).toHaveBeenCalled()
|
||||
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('')
|
||||
component.clearThemeColor()
|
||||
})
|
||||
|
||||
it('should support delete saved view', () => {
|
||||
completeSetup()
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const deleteSpy = jest.spyOn(savedViewService, 'delete')
|
||||
deleteSpy.mockReturnValue(of(true))
|
||||
component.deleteSavedView(savedViews[0] as PaperlessSavedView)
|
||||
expect(deleteSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
`Saved view "${savedViews[0].name}" deleted.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should show errors on load if load users failure', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(userService, 'listAll')
|
||||
.mockImplementation(() =>
|
||||
throwError(() => new Error('failed to load users'))
|
||||
)
|
||||
completeSetup(userService)
|
||||
fixture.detectChanges()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
})
|
||||
|
||||
it('should show errors on load if load groups failure', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(groupService, 'listAll')
|
||||
.mockImplementation(() =>
|
||||
throwError(() => new Error('failed to load groups'))
|
||||
)
|
||||
completeSetup(groupService)
|
||||
fixture.detectChanges()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
})
|
||||
})
|
551
src-ui/src/app/components/admin/settings/settings.component.ts
Normal file
551
src-ui/src/app/components/admin/settings/settings.component.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
import { ViewportScroller } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
Inject,
|
||||
LOCALE_ID,
|
||||
} from '@angular/core'
|
||||
import { FormGroup, FormControl } from '@angular/forms'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Subscription,
|
||||
Observable,
|
||||
Subject,
|
||||
first,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import {
|
||||
PermissionsService,
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import {
|
||||
SettingsService,
|
||||
LanguageOption,
|
||||
} from 'src/app/services/settings.service'
|
||||
import { ToastService, Toast } from 'src/app/services/toast.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
|
||||
enum SettingsNavIDs {
|
||||
General = 1,
|
||||
Permissions = 2,
|
||||
Notifications = 3,
|
||||
SavedViews = 4,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-settings',
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss'],
|
||||
})
|
||||
export class SettingsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
|
||||
{
|
||||
SettingsNavIDs = SettingsNavIDs
|
||||
activeNavID: number
|
||||
|
||||
savedViewGroup = new FormGroup({})
|
||||
|
||||
settingsForm = new FormGroup({
|
||||
bulkEditConfirmationDialogs: new FormControl(null),
|
||||
bulkEditApplyOnClose: new FormControl(null),
|
||||
documentListItemPerPage: new FormControl(null),
|
||||
slimSidebarEnabled: new FormControl(null),
|
||||
darkModeUseSystem: new FormControl(null),
|
||||
darkModeEnabled: new FormControl(null),
|
||||
darkModeInvertThumbs: new FormControl(null),
|
||||
themeColor: new FormControl(null),
|
||||
useNativePdfViewer: new FormControl(null),
|
||||
displayLanguage: new FormControl(null),
|
||||
dateLocale: new FormControl(null),
|
||||
dateFormat: new FormControl(null),
|
||||
notesEnabled: new FormControl(null),
|
||||
updateCheckingEnabled: new FormControl(null),
|
||||
defaultPermsOwner: new FormControl(null),
|
||||
defaultPermsViewUsers: new FormControl(null),
|
||||
defaultPermsViewGroups: new FormControl(null),
|
||||
defaultPermsEditUsers: new FormControl(null),
|
||||
defaultPermsEditGroups: new FormControl(null),
|
||||
|
||||
notificationsConsumerNewDocument: new FormControl(null),
|
||||
notificationsConsumerSuccess: new FormControl(null),
|
||||
notificationsConsumerFailed: new FormControl(null),
|
||||
notificationsConsumerSuppressOnDashboard: new FormControl(null),
|
||||
|
||||
savedViewsWarnOnUnsavedChange: new FormControl(null),
|
||||
savedViews: this.savedViewGroup,
|
||||
})
|
||||
|
||||
savedViews: PaperlessSavedView[]
|
||||
|
||||
store: BehaviorSubject<any>
|
||||
storeSub: Subscription
|
||||
isDirty$: Observable<boolean>
|
||||
isDirty: boolean = false
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
savePending: boolean = false
|
||||
|
||||
users: PaperlessUser[]
|
||||
groups: PaperlessGroup[]
|
||||
|
||||
get computedDateLocale(): string {
|
||||
return (
|
||||
this.settingsForm.value.dateLocale ||
|
||||
this.settingsForm.value.displayLanguage ||
|
||||
this.currentLocale
|
||||
)
|
||||
}
|
||||
|
||||
constructor(
|
||||
public savedViewService: SavedViewService,
|
||||
private documentListViewService: DocumentListViewService,
|
||||
private toastService: ToastService,
|
||||
private settings: SettingsService,
|
||||
@Inject(LOCALE_ID) public currentLocale: string,
|
||||
private viewportScroller: ViewportScroller,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
public readonly tourService: TourService,
|
||||
private usersService: UserService,
|
||||
private groupsService: GroupService,
|
||||
private router: Router,
|
||||
public permissionsService: PermissionsService
|
||||
) {
|
||||
super()
|
||||
this.settings.settingsSaved.subscribe(() => {
|
||||
if (!this.savePending) this.initialize()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initialize()
|
||||
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.User
|
||||
)
|
||||
) {
|
||||
this.usersService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.users = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Group
|
||||
)
|
||||
) {
|
||||
this.groupsService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.groups = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.SavedView
|
||||
)
|
||||
) {
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize(false)
|
||||
})
|
||||
}
|
||||
|
||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||
const section = paramMap.get('section')
|
||||
if (section) {
|
||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||
(navID) => navID.toLowerCase() == section
|
||||
)
|
||||
if (navIDKey) {
|
||||
this.activeNavID = SettingsNavIDs[navIDKey]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.activatedRoute.snapshot.fragment) {
|
||||
this.viewportScroller.scrollToAnchor(
|
||||
this.activatedRoute.snapshot.fragment
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentSettings() {
|
||||
return {
|
||||
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
|
||||
),
|
||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||
darkModeInvertThumbs: this.settings.get(
|
||||
SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED
|
||||
),
|
||||
themeColor: this.settings.get(SETTINGS_KEYS.THEME_COLOR),
|
||||
useNativePdfViewer: this.settings.get(
|
||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||
),
|
||||
displayLanguage: this.settings.getLanguage(),
|
||||
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
|
||||
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
|
||||
notesEnabled: this.settings.get(SETTINGS_KEYS.NOTES_ENABLED),
|
||||
updateCheckingEnabled: this.settings.get(
|
||||
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
|
||||
),
|
||||
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
|
||||
),
|
||||
savedViewsWarnOnUnsavedChange: this.settings.get(
|
||||
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
|
||||
),
|
||||
defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
|
||||
defaultPermsViewUsers: this.settings.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
|
||||
),
|
||||
defaultPermsViewGroups: this.settings.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS
|
||||
),
|
||||
defaultPermsEditUsers: this.settings.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS
|
||||
),
|
||||
defaultPermsEditGroups: this.settings.get(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||
),
|
||||
savedViews: {},
|
||||
}
|
||||
}
|
||||
|
||||
onNavChange(navChangeEvent: NgbNavChangeEvent) {
|
||||
const [foundNavIDkey] = Object.entries(SettingsNavIDs).find(
|
||||
([, navIDValue]) => navIDValue == navChangeEvent.nextId
|
||||
)
|
||||
if (foundNavIDkey)
|
||||
// if its dirty we need to wait for confirmation
|
||||
this.router
|
||||
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
||||
.then((navigated) => {
|
||||
if (!navigated && this.isDirty) {
|
||||
this.activeNavID = navChangeEvent.activeId
|
||||
} else if (navigated && this.isDirty) {
|
||||
this.initialize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
initialize(resetSettings: boolean = true) {
|
||||
this.unsubscribeNotifier.next(true)
|
||||
|
||||
const currentFormValue = this.settingsForm.value
|
||||
|
||||
let storeData = this.getCurrentSettings()
|
||||
|
||||
if (this.savedViews) {
|
||||
this.emptyGroup(this.savedViewGroup)
|
||||
|
||||
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(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())
|
||||
|
||||
// Record dirty in case we need to 'undo' appearance settings if not saved on close
|
||||
this.isDirty$
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((dirty) => {
|
||||
this.isDirty = dirty
|
||||
})
|
||||
|
||||
// "Live" visual changes prior to save
|
||||
this.settingsForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.settings.updateAppearanceSettings(
|
||||
this.settingsForm.get('darkModeUseSystem').value,
|
||||
this.settingsForm.get('darkModeEnabled').value,
|
||||
this.settingsForm.get('themeColor').value
|
||||
)
|
||||
})
|
||||
|
||||
if (!resetSettings && currentFormValue) {
|
||||
// prevents loss of unsaved changes
|
||||
this.settingsForm.patchValue(currentFormValue)
|
||||
}
|
||||
}
|
||||
|
||||
private emptyGroup(group: FormGroup) {
|
||||
Object.keys(group.controls).forEach((key) => group.removeControl(key))
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didnt save
|
||||
this.storeSub && this.storeSub.unsubscribe()
|
||||
}
|
||||
|
||||
deleteSavedView(savedView: PaperlessSavedView) {
|
||||
this.savedViewService.delete(savedView).subscribe(() => {
|
||||
this.savedViewGroup.removeControl(savedView.id.toString())
|
||||
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved view "${savedView.name}" deleted.`
|
||||
)
|
||||
this.savedViewService.clearCache()
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private saveLocalSettings() {
|
||||
this.savePending = true
|
||||
const reloadRequired =
|
||||
this.settingsForm.value.displayLanguage !=
|
||||
this.store?.getValue()['displayLanguage'] || // displayLanguage is dirty
|
||||
(this.settingsForm.value.updateCheckingEnabled !=
|
||||
this.store?.getValue()['updateCheckingEnabled'] &&
|
||||
this.settingsForm.value.updateCheckingEnabled) // update checking was turned on
|
||||
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE,
|
||||
this.settingsForm.value.bulkEditApplyOnClose
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS,
|
||||
this.settingsForm.value.bulkEditConfirmationDialogs
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE,
|
||||
this.settingsForm.value.documentListItemPerPage
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||
this.settingsForm.value.slimSidebarEnabled
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||
this.settingsForm.value.darkModeUseSystem
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DARK_MODE_ENABLED,
|
||||
(this.settingsForm.value.darkModeEnabled == true).toString()
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED,
|
||||
(this.settingsForm.value.darkModeInvertThumbs == true).toString()
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.THEME_COLOR,
|
||||
this.settingsForm.value.themeColor.toString()
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,
|
||||
this.settingsForm.value.useNativePdfViewer
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DATE_LOCALE,
|
||||
this.settingsForm.value.dateLocale
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DATE_FORMAT,
|
||||
this.settingsForm.value.dateFormat
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
|
||||
this.settingsForm.value.notificationsConsumerNewDocument
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
|
||||
this.settingsForm.value.notificationsConsumerSuccess
|
||||
)
|
||||
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.set(
|
||||
SETTINGS_KEYS.NOTES_ENABLED,
|
||||
this.settingsForm.value.notesEnabled
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED,
|
||||
this.settingsForm.value.updateCheckingEnabled
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE,
|
||||
this.settingsForm.value.savedViewsWarnOnUnsavedChange
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
|
||||
this.settingsForm.value.defaultPermsOwner
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS,
|
||||
this.settingsForm.value.defaultPermsViewUsers
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS,
|
||||
this.settingsForm.value.defaultPermsViewGroups
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS,
|
||||
this.settingsForm.value.defaultPermsEditUsers
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
||||
this.settingsForm.value.defaultPermsEditGroups
|
||||
)
|
||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||
this.settings
|
||||
.storeSettings()
|
||||
.pipe(first())
|
||||
.pipe(tap(() => (this.savePending = false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.store.next(this.settingsForm.value)
|
||||
this.documentListViewService.updatePageSize()
|
||||
this.settings.updateAppearanceSettings()
|
||||
let savedToast: Toast = {
|
||||
title: $localize`Settings saved`,
|
||||
content: $localize`Settings were saved successfully.`,
|
||||
delay: 5000,
|
||||
}
|
||||
if (reloadRequired) {
|
||||
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
|
||||
savedToast.actionName = $localize`Reload now`
|
||||
savedToast.action = () => {
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
this.toastService.show(savedToast)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`An error occurred while saving settings.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
get displayLanguageOptions(): LanguageOption[] {
|
||||
return [{ code: '', name: $localize`Use system language` }].concat(
|
||||
this.settings.getLanguageOptions()
|
||||
)
|
||||
}
|
||||
|
||||
get dateLocaleOptions(): LanguageOption[] {
|
||||
return [
|
||||
{ code: '', name: $localize`Use date format of display language` },
|
||||
].concat(this.settings.getDateLocaleOptions())
|
||||
}
|
||||
|
||||
get today() {
|
||||
return new Date()
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
// only patch views that have actually changed
|
||||
const changed: PaperlessSavedView[] = []
|
||||
Object.values(this.savedViewGroup.controls)
|
||||
.filter((g: FormGroup) => !g.pristine)
|
||||
.forEach((group: FormGroup) => {
|
||||
changed.push(group.value)
|
||||
})
|
||||
if (changed.length > 0) {
|
||||
this.savedViewService.patchMany(changed).subscribe({
|
||||
next: () => {
|
||||
this.saveLocalSettings()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error while storing settings on server.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
this.saveLocalSettings()
|
||||
}
|
||||
}
|
||||
|
||||
clearThemeColor() {
|
||||
this.settingsForm.get('themeColor').patchValue('')
|
||||
}
|
||||
}
|
134
src-ui/src/app/components/admin/tasks/tasks.component.html
Normal file
134
src-ui/src/app/components/admin/tasks/tasks.component.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<pngx-page-header title="File Tasks" i18n-title>
|
||||
<div class="btn-toolbar col col-md-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Clear selection</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#check2-all"/>
|
||||
</svg> <ng-container i18n>{{dismissButtonText}}</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="tasksService.reload()">
|
||||
<svg *ngIf="!tasksService.loading" class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-clockwise"/>
|
||||
</svg>
|
||||
<ng-container *ngIf="tasksService.loading">
|
||||
<div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</ng-container> <ng-container i18n>Refresh</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
<ng-container *ngIf="!tasksService.completedFileTasks && tasksService.loading">
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template let-tasks="tasks" #tasksTemplate>
|
||||
<table class="table table-striped align-middle border shadow-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-tasks"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" i18n>Name</th>
|
||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
||||
<th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'" i18n>Results</th>
|
||||
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
|
||||
<th scope="col" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ng-container *ngFor="let task of tasks | slice: (page-1) * pageSize : page * pageSize">
|
||||
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="task{{task.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="overflow-auto name-col">{{ task.task_file_name }}</td>
|
||||
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
||||
<td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'">
|
||||
<div *ngIf="task.result?.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();"
|
||||
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
|
||||
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span>
|
||||
</div>
|
||||
<span *ngIf="task.result?.length <= 50" class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result }}</span>
|
||||
<ng-template #resultPopover>
|
||||
<pre class="small mb-0">{{ task.result | slice:0:300 }}<ng-container *ngIf="task.result.length > 300">…</ng-container></pre>
|
||||
<ng-container *ngIf="task.result?.length > 300"><br/><em>(<ng-container i18n>click for full output</ng-container>)</em></ng-container>
|
||||
</ng-template>
|
||||
</td>
|
||||
<td class="d-lg-none">
|
||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
||||
<svg fill="currentColor" class="" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#check"/>
|
||||
</svg> <ng-container i18n>Dismiss</ng-container>
|
||||
</button>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<button *ngIf="task.related_document" class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
||||
</svg> <ng-container i18n>Open Document</ng-container>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5">
|
||||
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pb-3 d-sm-flex justify-content-between align-items-center">
|
||||
<div class="pb-2 pb-sm-0" i18n *ngIf="tasks.length > 0">{tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}}</div>
|
||||
<ngb-pagination *ngIf="tasks.length > pageSize" [(page)]="page" [pageSize]="pageSize" [collectionSize]="tasks.length" maxSize="8" size="sm"></ngb-pagination>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)">
|
||||
<li ngbNavItem="failed">
|
||||
<a ngbNavLink i18n>Failed<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li ngbNavItem="completed">
|
||||
<a ngbNavLink i18n>Complete<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li ngbNavItem="started">
|
||||
<a ngbNavLink i18n>Started<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li ngbNavItem="queued">
|
||||
<a ngbNavLink i18n>Queued<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span></a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
28
src-ui/src/app/components/admin/tasks/tasks.component.scss
Normal file
28
src-ui/src/app/components/admin/tasks/tasks.component.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
::ng-deep .popover {
|
||||
max-width: 350px;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.result {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn .spinner-border-sm {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.name-col {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
272
src-ui/src/app/components/admin/tasks/tasks.component.spec.ts
Normal file
272
src-ui/src/app/components/admin/tasks/tasks.component.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import {
|
||||
HttpTestingController,
|
||||
HttpClientTestingModule,
|
||||
} from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModule,
|
||||
NgbNavItem,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import {
|
||||
PaperlessTask,
|
||||
PaperlessTaskType,
|
||||
PaperlessTaskStatus,
|
||||
} from 'src/app/data/paperless-task'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { TasksComponent } from './tasks.component'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
|
||||
const tasks: PaperlessTask[] = [
|
||||
{
|
||||
id: 467,
|
||||
task_id: '11ca1a5b-9f81-442c-b2c8-7e4ae53657f1',
|
||||
task_file_name: 'test.pdf',
|
||||
date_created: new Date('2023-03-01T10:26:03.093116Z'),
|
||||
date_done: new Date('2023-03-01T10:26:07.223048Z'),
|
||||
type: PaperlessTaskType.File,
|
||||
status: PaperlessTaskStatus.Failed,
|
||||
result: 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
|
||||
acknowledged: false,
|
||||
related_document: null,
|
||||
},
|
||||
{
|
||||
id: 466,
|
||||
task_id: '10ca1a5b-3c08-442c-b2c8-7e4ae53657f1',
|
||||
task_file_name: '191092.pdf',
|
||||
date_created: new Date('2023-03-01T09:26:03.093116Z'),
|
||||
date_done: new Date('2023-03-01T09:26:07.223048Z'),
|
||||
type: PaperlessTaskType.File,
|
||||
status: PaperlessTaskStatus.Failed,
|
||||
result:
|
||||
'191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
|
||||
acknowledged: false,
|
||||
related_document: null,
|
||||
},
|
||||
{
|
||||
id: 465,
|
||||
task_id: '3612d477-bb04-44e3-985b-ac580dd496d8',
|
||||
task_file_name: 'Scan Jun 6, 2023 at 3.19 PM.pdf',
|
||||
date_created: new Date('2023-06-06T15:22:05.722323-07:00'),
|
||||
date_done: new Date('2023-06-06T15:22:14.564305-07:00'),
|
||||
type: PaperlessTaskType.File,
|
||||
status: PaperlessTaskStatus.Pending,
|
||||
result: null,
|
||||
acknowledged: false,
|
||||
related_document: null,
|
||||
},
|
||||
{
|
||||
id: 464,
|
||||
task_id: '2eac4716-2aa6-4dcd-9953-264e11656d7e',
|
||||
task_file_name: 'paperless-mail-l4dkg8ir',
|
||||
date_created: new Date('2023-06-04T11:24:32.898089-07:00'),
|
||||
date_done: new Date('2023-06-04T11:24:44.678605-07:00'),
|
||||
type: PaperlessTaskType.File,
|
||||
status: PaperlessTaskStatus.Complete,
|
||||
result: 'Success. New document id 422 created',
|
||||
acknowledged: false,
|
||||
related_document: 422,
|
||||
},
|
||||
{
|
||||
id: 463,
|
||||
task_id: '28125528-1575-4d6b-99e6-168906e8fa5c',
|
||||
task_file_name: 'onlinePaymentSummary.pdf',
|
||||
date_created: new Date('2023-06-01T13:49:51.631305-07:00'),
|
||||
date_done: new Date('2023-06-01T13:49:54.190220-07:00'),
|
||||
type: PaperlessTaskType.File,
|
||||
status: PaperlessTaskStatus.Complete,
|
||||
result: 'Success. New document id 421 created',
|
||||
acknowledged: false,
|
||||
related_document: 421,
|
||||
},
|
||||
{
|
||||
id: 462,
|
||||
task_id: 'a5b9ca47-0c8e-490f-a04c-6db5d5fc09e5',
|
||||
task_file_name: 'paperless-mail-_rrpmqk6',
|
||||
date_created: new Date('2023-06-07T02:54:35.694916Z'),
|
||||
date_done: null,
|
||||
type: PaperlessTaskType.File,
|
||||
status: PaperlessTaskStatus.Started,
|
||||
result: null,
|
||||
acknowledged: false,
|
||||
related_document: null,
|
||||
},
|
||||
]
|
||||
|
||||
describe('TasksComponent', () => {
|
||||
let component: TasksComponent
|
||||
let fixture: ComponentFixture<TasksComponent>
|
||||
let tasksService: TasksService
|
||||
let modalService: NgbModal
|
||||
let router: Router
|
||||
let httpTestingController: HttpTestingController
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
TasksComponent,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
ConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
},
|
||||
},
|
||||
CustomDatePipe,
|
||||
DatePipe,
|
||||
PermissionsGuard,
|
||||
],
|
||||
imports: [
|
||||
NgbModule,
|
||||
HttpClientTestingModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
tasksService = TestBed.inject(TasksService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
fixture = TestBed.createComponent(TasksComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}tasks/`)
|
||||
.flush(tasks)
|
||||
})
|
||||
|
||||
it('should display file tasks in 4 tabs by status', () => {
|
||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavItem))
|
||||
|
||||
let currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Failed
|
||||
).length
|
||||
component.activeTab = 'failed'
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[0].nativeElement.textContent).toEqual(
|
||||
`Failed${currentTasksLength}`
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('input[type="checkbox"]'))
|
||||
).toHaveLength(currentTasksLength + 1)
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Complete
|
||||
).length
|
||||
component.activeTab = 'completed'
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[1].nativeElement.textContent).toEqual(
|
||||
`Complete${currentTasksLength}`
|
||||
)
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Started
|
||||
).length
|
||||
component.activeTab = 'started'
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[2].nativeElement.textContent).toEqual(
|
||||
`Started${currentTasksLength}`
|
||||
)
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Pending
|
||||
).length
|
||||
component.activeTab = 'queued'
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[3].nativeElement.textContent).toEqual(
|
||||
`Queued${currentTasksLength}`
|
||||
)
|
||||
})
|
||||
|
||||
it('should to go page 1 between tab switch', () => {
|
||||
component.page = 10
|
||||
component.duringTabChange(2)
|
||||
expect(component.page).toEqual(1)
|
||||
})
|
||||
|
||||
it('should support expanding / collapsing one task at a time', () => {
|
||||
component.expandTask(tasks[0])
|
||||
expect(component.expandedTask).toEqual(tasks[0].id)
|
||||
component.expandTask(tasks[1])
|
||||
expect(component.expandedTask).toEqual(tasks[1].id)
|
||||
component.expandTask(tasks[1])
|
||||
expect(component.expandedTask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should support dismiss single task', () => {
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
component.dismissTask(tasks[0])
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id]))
|
||||
})
|
||||
|
||||
it('should support dismiss specific checked tasks', () => {
|
||||
component.toggleSelected(tasks[0])
|
||||
component.toggleSelected(tasks[1])
|
||||
component.toggleSelected(tasks[3])
|
||||
component.toggleSelected(tasks[3]) // uncheck, for coverage
|
||||
const selected = new Set([tasks[0].id, tasks[1].id])
|
||||
expect(component.selectedTasks).toEqual(selected)
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
fixture.detectChanges()
|
||||
component.dismissTasks()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
||||
})
|
||||
|
||||
it('should support dismiss all tasks', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
component.dismissTasks()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set(tasks.map((t) => t.id)))
|
||||
})
|
||||
|
||||
it('should support toggle all tasks', () => {
|
||||
const toggleCheck = fixture.debugElement.query(
|
||||
By.css('input[type=checkbox]')
|
||||
)
|
||||
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
expect(component.selectedTasks).toEqual(
|
||||
new Set(
|
||||
tasks
|
||||
.filter((t) => t.status === PaperlessTaskStatus.Failed)
|
||||
.map((t) => t.id)
|
||||
)
|
||||
)
|
||||
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
expect(component.selectedTasks).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('should support dismiss and open a document', () => {
|
||||
const routerSpy = jest.spyOn(router, 'navigate')
|
||||
component.dismissAndGo(tasks[3])
|
||||
expect(routerSpy).toHaveBeenCalledWith([
|
||||
'documents',
|
||||
tasks[3].related_document,
|
||||
])
|
||||
})
|
||||
})
|
139
src-ui/src/app/components/admin/tasks/tasks.component.ts
Normal file
139
src-ui/src/app/components/admin/tasks/tasks.component.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs'
|
||||
import { PaperlessTask } from 'src/app/data/paperless-task'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-tasks',
|
||||
templateUrl: './tasks.component.html',
|
||||
styleUrls: ['./tasks.component.scss'],
|
||||
})
|
||||
export class TasksComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
public activeTab: string
|
||||
public selectedTasks: Set<number> = new Set()
|
||||
public expandedTask: number
|
||||
|
||||
public pageSize: number = 25
|
||||
public page: number = 1
|
||||
|
||||
get dismissButtonText(): string {
|
||||
return this.selectedTasks.size > 0
|
||||
? $localize`Dismiss selected`
|
||||
: $localize`Dismiss all`
|
||||
}
|
||||
|
||||
constructor(
|
||||
public tasksService: TasksService,
|
||||
private modalService: NgbModal,
|
||||
private readonly router: Router
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.tasksService.reload()
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.tasksService.cancelPending()
|
||||
}
|
||||
|
||||
dismissTask(task: PaperlessTask) {
|
||||
this.dismissTasks(task)
|
||||
}
|
||||
|
||||
dismissTasks(task: PaperlessTask = undefined) {
|
||||
let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values())
|
||||
if (!task && tasks.size == 0)
|
||||
tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id))
|
||||
if (tasks.size > 1) {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
||||
modal.componentInstance.messageBold =
|
||||
$localize`Dismiss all` + ` ${tasks.size} ` + $localize`tasks?`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.tasksService.dismissTasks(tasks)
|
||||
this.selectedTasks.clear()
|
||||
})
|
||||
} else {
|
||||
this.tasksService.dismissTasks(tasks)
|
||||
this.selectedTasks.clear()
|
||||
}
|
||||
}
|
||||
|
||||
dismissAndGo(task: PaperlessTask) {
|
||||
this.dismissTask(task)
|
||||
this.router.navigate(['documents', task.related_document])
|
||||
}
|
||||
|
||||
expandTask(task: PaperlessTask) {
|
||||
this.expandedTask = this.expandedTask == task.id ? undefined : task.id
|
||||
}
|
||||
|
||||
toggleSelected(task: PaperlessTask) {
|
||||
this.selectedTasks.has(task.id)
|
||||
? this.selectedTasks.delete(task.id)
|
||||
: this.selectedTasks.add(task.id)
|
||||
}
|
||||
|
||||
get currentTasks(): PaperlessTask[] {
|
||||
let tasks: PaperlessTask[] = []
|
||||
switch (this.activeTab) {
|
||||
case 'queued':
|
||||
tasks = this.tasksService.queuedFileTasks
|
||||
break
|
||||
case 'started':
|
||||
tasks = this.tasksService.startedFileTasks
|
||||
break
|
||||
case 'completed':
|
||||
tasks = this.tasksService.completedFileTasks
|
||||
break
|
||||
case 'failed':
|
||||
tasks = this.tasksService.failedFileTasks
|
||||
break
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
toggleAll(event: PointerEvent) {
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
this.selectedTasks = new Set(this.currentTasks.map((t) => t.id))
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedTasks.clear()
|
||||
}
|
||||
|
||||
duringTabChange(navID: number) {
|
||||
this.page = 1
|
||||
}
|
||||
|
||||
get activeTabLocalized(): string {
|
||||
switch (this.activeTab) {
|
||||
case 'queued':
|
||||
return $localize`queued`
|
||||
case 'started':
|
||||
return $localize`started`
|
||||
case 'completed':
|
||||
return $localize`completed`
|
||||
case 'failed':
|
||||
return $localize`failed`
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,99 @@
|
||||
<pngx-page-header title="Users & Groups" i18n-title>
|
||||
</pngx-page-header>
|
||||
|
||||
<ng-container *ngIf="users">
|
||||
<h4 class="d-flex">
|
||||
<ng-container i18n>Users</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add User</ng-container>
|
||||
</button>
|
||||
</h4>
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Username</div>
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Groups</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li *ngFor="let user of users" class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
|
||||
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="groups">
|
||||
<h4 class="mt-4 d-flex">
|
||||
<ng-container i18n>Groups</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Group</ng-container>
|
||||
</button>
|
||||
</h4>
|
||||
<ul *ngIf="groups.length > 0" class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li *ngFor="let group of groups" class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div *ngIf="groups.length === 0">No groups defined</div>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="!users || !groups">
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</div>
|
@@ -0,0 +1,267 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModule,
|
||||
NgbAlertModule,
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { throwError, of } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
import { NumberComponent } from '../../common/input/number/number.component'
|
||||
import { PasswordComponent } from '../../common/input/password/password.component'
|
||||
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
||||
import { SelectComponent } from '../../common/input/select/select.component'
|
||||
import { TagsComponent } from '../../common/input/tags/tags.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { SettingsComponent } from '../settings/settings.component'
|
||||
import { UsersAndGroupsComponent } from './users-groups.component'
|
||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
||||
|
||||
const users = [
|
||||
{ id: 1, username: 'user1', is_superuser: false },
|
||||
{ id: 2, username: 'user2', is_superuser: false },
|
||||
]
|
||||
const groups = [
|
||||
{ id: 1, name: 'group1' },
|
||||
{ id: 2, name: 'group2' },
|
||||
]
|
||||
|
||||
describe('UsersAndGroupsComponent', () => {
|
||||
let component: UsersAndGroupsComponent
|
||||
let fixture: ComponentFixture<UsersAndGroupsComponent>
|
||||
let settingsService: SettingsService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
let userService: UserService
|
||||
let permissionsService: PermissionsService
|
||||
let groupService: GroupService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
UsersAndGroupsComponent,
|
||||
SettingsComponent,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
ConfirmDialogComponent,
|
||||
CheckComponent,
|
||||
SafeHtmlPipe,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
PasswordComponent,
|
||||
NumberComponent,
|
||||
TagsComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
IfOwnerDirective,
|
||||
],
|
||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||
imports: [
|
||||
NgbModule,
|
||||
HttpClientTestingModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbAlertModule,
|
||||
NgSelectModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = users[0]
|
||||
userService = TestBed.inject(UserService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserOwnsObject')
|
||||
.mockReturnValue(true)
|
||||
groupService = TestBed.inject(GroupService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
function completeSetup(excludeService = null) {
|
||||
if (excludeService !== userService) {
|
||||
jest.spyOn(userService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: users.map((a) => a.id),
|
||||
count: users.length,
|
||||
results: (users as PaperlessUser[]).concat([]),
|
||||
})
|
||||
)
|
||||
}
|
||||
if (excludeService !== groupService) {
|
||||
jest.spyOn(groupService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: groups.map((r) => r.id),
|
||||
count: groups.length,
|
||||
results: (groups as PaperlessGroup[]).concat([]),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
}
|
||||
|
||||
it('should support edit / create user, show error if needed', () => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||
component.editUser(users[0])
|
||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
editDialog.failed.emit()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
settingsService.currentUser = users[1] // simulate logged in as different user
|
||||
editDialog.succeeded.emit(users[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalledWith(
|
||||
`Saved user "${users[0].username}".`
|
||||
)
|
||||
component.editUser()
|
||||
})
|
||||
|
||||
it('should support delete user, show error if needed', () => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||
component.deleteUser(users[0])
|
||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
const deleteSpy = jest.spyOn(userService, 'delete')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const listAllSpy = jest.spyOn(userService, 'listAll')
|
||||
deleteSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('error deleting user'))
|
||||
)
|
||||
deleteDialog.confirm()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
deleteDialog.confirm()
|
||||
expect(listAllSpy).toHaveBeenCalled()
|
||||
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user')
|
||||
})
|
||||
|
||||
it('should logout current user if password changed, after delay', fakeAsync(() => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||
component.editUser(users[0])
|
||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||
editDialog.passwordIsSet = true
|
||||
settingsService.currentUser = users[0] // simulate logged in as same user
|
||||
editDialog.succeeded.emit(users[0])
|
||||
fixture.detectChanges()
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: 'http://localhost/',
|
||||
},
|
||||
writable: true, // possibility to override
|
||||
})
|
||||
tick(2600)
|
||||
expect(window.location.href).toContain('logout')
|
||||
}))
|
||||
|
||||
it('should support edit / create group, show error if needed', () => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||
component.editGroup(groups[0])
|
||||
const editDialog = modal.componentInstance as GroupEditDialogComponent
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
editDialog.failed.emit()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
editDialog.succeeded.emit(groups[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalledWith(
|
||||
`Saved group "${groups[0].name}".`
|
||||
)
|
||||
component.editGroup()
|
||||
})
|
||||
|
||||
it('should support delete group, show error if needed', () => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||
component.deleteGroup(users[0])
|
||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
const deleteSpy = jest.spyOn(groupService, 'delete')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const listAllSpy = jest.spyOn(groupService, 'listAll')
|
||||
deleteSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('error deleting group'))
|
||||
)
|
||||
deleteDialog.confirm()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
deleteDialog.confirm()
|
||||
expect(listAllSpy).toHaveBeenCalled()
|
||||
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group')
|
||||
})
|
||||
|
||||
it('should get group name', () => {
|
||||
completeSetup()
|
||||
expect(component.getGroupName(1)).toEqual(groups[0].name)
|
||||
expect(component.getGroupName(11)).toEqual('')
|
||||
})
|
||||
|
||||
it('should show errors on load if load users failure', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(userService, 'listAll')
|
||||
.mockImplementation(() =>
|
||||
throwError(() => new Error('failed to load users'))
|
||||
)
|
||||
completeSetup(userService)
|
||||
fixture.detectChanges()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
})
|
||||
|
||||
it('should show errors on load if load groups failure', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(groupService, 'listAll')
|
||||
.mockImplementation(() =>
|
||||
throwError(() => new Error('failed to load groups'))
|
||||
)
|
||||
completeSetup(groupService)
|
||||
fixture.detectChanges()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
})
|
||||
})
|
@@ -0,0 +1,189 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, first, takeUntil } from 'rxjs'
|
||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-users-groups',
|
||||
templateUrl: './users-groups.component.html',
|
||||
styleUrls: ['./users-groups.component.scss'],
|
||||
})
|
||||
export class UsersAndGroupsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
users: PaperlessUser[]
|
||||
groups: PaperlessGroup[]
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private groupsService: GroupService,
|
||||
private toastService: ToastService,
|
||||
private modalService: NgbModal,
|
||||
public permissionsService: PermissionsService,
|
||||
private settings: SettingsService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.usersService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.users = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, e)
|
||||
},
|
||||
})
|
||||
|
||||
this.groupsService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.groups = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.unsubscribeNotifier.next(true)
|
||||
}
|
||||
|
||||
editUser(user: PaperlessUser = null) {
|
||||
var modal = this.modalService.open(UserEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
})
|
||||
modal.componentInstance.dialogMode = user
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = user
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newUser: PaperlessUser) => {
|
||||
if (
|
||||
newUser.id === this.settings.currentUser.id &&
|
||||
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
||||
) {
|
||||
this.toastService.showInfo(
|
||||
$localize`Password has been changed, you will be logged out momentarily.`
|
||||
)
|
||||
setTimeout(() => {
|
||||
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/`
|
||||
}, 2500)
|
||||
} else {
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved user "${newUser.username}".`
|
||||
)
|
||||
this.usersService.listAll().subscribe((r) => {
|
||||
this.users = r.results
|
||||
})
|
||||
}
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving user.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteUser(user: PaperlessUser) {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete user account`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this user account.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.usersService.delete(user).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted user`)
|
||||
this.usersService.listAll().subscribe((r) => {
|
||||
this.users = r.results
|
||||
})
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error deleting user.`, e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
editGroup(group: PaperlessGroup = null) {
|
||||
var modal = this.modalService.open(GroupEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
modal.componentInstance.dialogMode = group
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = group
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newGroup) => {
|
||||
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
||||
this.groupsService.listAll().subscribe((r) => {
|
||||
this.groups = r.results
|
||||
})
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving group.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteGroup(group: PaperlessGroup) {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete user group`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this user group.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.groupsService.delete(group).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted group`)
|
||||
this.groupsService.listAll().subscribe((r) => {
|
||||
this.groups = r.results
|
||||
})
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error deleting group.`, e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getGroupName(id: number): string {
|
||||
return this.groups?.find((g) => g.id === id)?.name ?? ''
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user