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:
shamoon
2023-09-24 19:24:28 -07:00
committed by GitHub
parent dc642152d1
commit b5d29e821e
39 changed files with 4098 additions and 3760 deletions

View File

@@ -1,33 +0,0 @@
<pngx-page-header title="Consumption Templates">
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Template</ng-container>
</button>
</pngx-page-header>
<table class="table table-striped align-middle border shadow-sm">
<thead>
<tr>
<th scope="col" i18n>Name</th>
<th scope="col" i18n>Sort order</th>
<th scope="col" i18n>Document Sources</th>
<th scope="col" i18n>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let template of templates">
<td scope="row"><button class="btn btn-link p-0" type="button" (click)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></td>
<td scope="row"><code>{{template.order}}</code></td>
<td scope="row">{{getSourceList(template)}}</td>
<td scope="row">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-primary" type="button" (click)="editTemplate(template)" i18n>Edit</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)" i18n>Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
<div *ngIf="templates.length === 0" i18n>No templates defined.</div>

View File

@@ -0,0 +1,44 @@
<pngx-page-header title="Consumption Templates">
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Template</ng-container>
</button>
</pngx-page-header>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Sort order</div>
<div class="col" i18n>Document Sources</div>
<div class="col" i18n>Actions</div>
</div>
</li>
<li *ngFor="let template of templates" 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)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></div>
<div class="col d-flex align-items-center"><code>{{template.order}}</code></div>
<div class="col d-flex align-items-center">{{getSourceList(template)}}</div>
<div class="col">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
</svg>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
</div>
</li>
</ul>
<div *ngIf="templates.length === 0" i18n>No templates defined.</div>

View File

@@ -18,7 +18,7 @@ import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-te
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ConsumptionTemplatesListComponent } from './consumption-templates-list.component'
import { ConsumptionTemplatesComponent } from './consumption-templates.component'
import { ConsumptionTemplateEditDialogComponent } from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -48,8 +48,8 @@ const templates: PaperlessConsumptionTemplate[] = [
]
describe('ConsumptionTemplatesComponent', () => {
let component: ConsumptionTemplatesListComponent
let fixture: ComponentFixture<ConsumptionTemplatesListComponent>
let component: ConsumptionTemplatesComponent
let fixture: ComponentFixture<ConsumptionTemplatesComponent>
let consumptionTemplateService: ConsumptionTemplateService
let modalService: NgbModal
let toastService: ToastService
@@ -57,7 +57,7 @@ describe('ConsumptionTemplatesComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
ConsumptionTemplatesListComponent,
ConsumptionTemplatesComponent,
IfPermissionsDirective,
PageHeaderComponent,
ConfirmDialogComponent,
@@ -92,7 +92,7 @@ describe('ConsumptionTemplatesComponent', () => {
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(ConsumptionTemplatesListComponent)
fixture = TestBed.createComponent(ConsumptionTemplatesComponent)
component = fixture.componentInstance
fixture.detectChanges()
})

View File

@@ -14,11 +14,11 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
@Component({
selector: 'pngx-consumption-templates-list',
templateUrl: './consumption-templates-list.component.html',
styleUrls: ['./consumption-templates-list.component.scss'],
selector: 'pngx-consumption-templates',
templateUrl: './consumption-templates.component.html',
styleUrls: ['./consumption-templates.component.scss'],
})
export class ConsumptionTemplatesListComponent
export class ConsumptionTemplatesComponent
extends ComponentWithPermissions
implements OnInit
{

View File

@@ -1,25 +0,0 @@
<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>

View File

@@ -1,26 +0,0 @@
.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;
}
}

View File

@@ -1,71 +0,0 @@
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)
})
})

View File

@@ -1,94 +0,0 @@
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',
})
}
}

View File

@@ -0,0 +1,107 @@
<pngx-page-header title="Mail Settings" i18n-title>
</pngx-page-header>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }">
<h4>
<ng-container i18n>Mail accounts</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailAccount()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Account</ng-container>
</button>
</h4>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Server</div>
<div class="col" i18n>Actions</div>
</div>
</li>
<li *ngFor="let account of mailAccounts" 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)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userCanEdit(account)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailAccount(account)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
</svg>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfOwner="account" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(account)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-lock" />
</svg>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
</div>
</li>
<div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div>
</ul>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
<h4 class="mt-4">
<ng-container i18n>Mail rules</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Rule</ng-container>
</button>
</h4>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Account</div>
<div class="col" i18n>Actions</div>
</div>
</li>
<li *ngFor="let rule of mailRules" 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)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailRule(rule)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
</svg>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfOwner="rule" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(rule)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-lock" />
</svg>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
</div>
</li>
<div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div>
</ul>
</ng-container>
<div *ngIf="!mailAccounts || !mailRules">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</div>

View File

@@ -0,0 +1,304 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { MailComponent } from './mail.component'
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbModule,
NgbAlertModule,
NgbModalRef,
NgbModal,
} 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 { PaperlessMailAccount } from 'src/app/data/paperless-mail-account'
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
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 { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-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 { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.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 { TextComponent } from '../../common/input/text/text.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { PermissionsService } from 'src/app/services/permissions.service'
import { ToastService } from 'src/app/services/toast.service'
import { TagsComponent } from '../../common/input/tags/tags.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
const mailAccounts = [
{ id: 1, name: 'account1' },
{ id: 2, name: 'account2' },
]
const mailRules = [
{ id: 1, name: 'rule1', owner: 1, account: 1 },
{ id: 2, name: 'rule2', owner: 2, account: 2 },
]
describe('MailComponent', () => {
let component: MailComponent
let fixture: ComponentFixture<MailComponent>
let mailAccountService: MailAccountService
let mailRuleService: MailRuleService
let modalService: NgbModal
let toastService: ToastService
let permissionsService: PermissionsService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
MailComponent,
PageHeaderComponent,
IfPermissionsDirective,
CustomDatePipe,
ConfirmDialogComponent,
CheckComponent,
SafeHtmlPipe,
SelectComponent,
TextComponent,
PasswordComponent,
NumberComponent,
MailAccountEditDialogComponent,
MailRuleEditDialogComponent,
IfOwnerDirective,
TagsComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
PermissionsDialogComponent,
PermissionsFormComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
NgbModule,
HttpClientTestingModule,
RouterTestingModule.withRoutes(routes),
FormsModule,
ReactiveFormsModule,
NgbAlertModule,
NgSelectModule,
],
}).compileComponents()
mailAccountService = TestBed.inject(MailAccountService)
mailRuleService = TestBed.inject(MailRuleService)
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)
fixture = TestBed.createComponent(MailComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
function completeSetup(excludeService = null) {
if (excludeService !== mailAccountService) {
jest.spyOn(mailAccountService, 'listAll').mockReturnValue(
of({
all: mailAccounts.map((a) => a.id),
count: mailAccounts.length,
results: (mailAccounts as PaperlessMailAccount[]).concat([]),
})
)
}
if (excludeService !== mailRuleService) {
jest.spyOn(mailRuleService, 'listAll').mockReturnValue(
of({
all: mailRules.map((r) => r.id),
count: mailRules.length,
results: (mailRules as PaperlessMailRule[]).concat([]),
})
)
}
fixture = TestBed.createComponent(MailComponent)
component = fixture.componentInstance
fixture.detectChanges()
}
it('should show errors on load if load mailAccounts failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailAccountService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail accounts'))
)
completeSetup(mailAccountService)
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load mailRules failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailRuleService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail rules'))
)
completeSetup(mailRuleService)
expect(toastErrorSpy).toBeCalled()
})
it('should support edit / create mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailAccount(mailAccounts[0] as PaperlessMailAccount)
let editDialog = modal.componentInstance as MailAccountEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
editDialog.succeeded.emit(mailAccounts[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved account "${mailAccounts[0].name}".`
)
editDialog.cancel()
component.editMailAccount()
})
it('should support delete mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount)
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailAccountService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail account'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account')
})
it('should support edit / create mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailRule(mailRules[0] as PaperlessMailRule)
const editDialog = modal.componentInstance as MailRuleEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
editDialog.succeeded.emit(mailRules[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved rule "${mailRules[0].name}".`
)
editDialog.cancel()
component.editMailRule()
})
it('should support delete mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailRule(mailRules[0] as PaperlessMailRule)
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailRuleService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule')
})
it('should support edit permissions on mail rule objects', () => {
completeSetup()
const perms = {
owner: 99,
set_permissions: {
view: {
users: [1],
groups: [2],
},
change: {
users: [3],
groups: [4],
},
},
}
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
component.editPermissions(mailRules[0] as PaperlessMailRule)
expect(modal).not.toBeUndefined()
let dialog = modal.componentInstance as PermissionsDialogComponent
expect(dialog.object).toEqual(mailRules[0])
rulePatchSpy.mockReturnValueOnce(
throwError(() => new Error('error saving perms'))
)
dialog.confirmClicked.emit(perms)
expect(rulePatchSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as PaperlessMailRule))
dialog.confirmClicked.emit(perms)
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
modalService.dismissAll()
})
it('should support edit permissions on mail account objects', () => {
completeSetup()
const perms = {
owner: 99,
set_permissions: {
view: {
users: [1],
groups: [2],
},
change: {
users: [3],
groups: [4],
},
},
}
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const accountPatchSpy = jest.spyOn(mailAccountService, 'patch')
component.editPermissions(mailAccounts[0] as PaperlessMailAccount)
expect(modal).not.toBeUndefined()
let dialog = modal.componentInstance as PermissionsDialogComponent
expect(dialog.object).toEqual(mailAccounts[0])
dialog = modal.componentInstance as PermissionsDialogComponent
dialog.confirmClicked.emit(perms)
expect(accountPatchSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,232 @@
import { Component, OnInit, OnDestroy } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account'
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
import {
PermissionsService,
PermissionAction,
} from 'src/app/services/permissions.service'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.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 { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
@Component({
selector: 'pngx-mail',
templateUrl: './mail.component.html',
styleUrls: ['./mail.component.scss'],
})
export class MailComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
mailAccounts: PaperlessMailAccount[] = []
mailRules: PaperlessMailRule[] = []
unsubscribeNotifier: Subject<any> = new Subject()
constructor(
public mailAccountService: MailAccountService,
public mailRuleService: MailRuleService,
private toastService: ToastService,
private modalService: NgbModal,
public permissionsService: PermissionsService
) {
super()
}
ngOnInit(): void {
this.mailAccountService
.listAll(null, null, { full_perms: true })
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (r) => {
this.mailAccounts = r.results
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving mail accounts`,
e
)
},
})
this.mailRuleService
.listAll(null, null, { full_perms: true })
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (r) => {
this.mailRules = r.results
},
error: (e) => {
this.toastService.showError($localize`Error retrieving mail rules`, e)
},
})
}
ngOnDestroy() {
this.unsubscribeNotifier.next(true)
}
editMailAccount(account: PaperlessMailAccount = null) {
const modal = this.modalService.open(MailAccountEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = account
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.object = account
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailAccount) => {
this.toastService.showInfo(
$localize`Saved account "${newMailAccount.name}".`
)
this.mailAccountService.clearCache()
this.mailAccountService
.listAll(null, null, { full_perms: true })
.subscribe((r) => {
this.mailAccounts = r.results
})
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving account.`, e)
})
}
deleteMailAccount(account: PaperlessMailAccount) {
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete mail account`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail 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.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail account`)
this.mailAccountService.clearCache()
this.mailAccountService
.listAll(null, null, { full_perms: true })
.subscribe((r) => {
this.mailAccounts = r.results
})
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting mail account.`,
e
)
},
})
})
}
editMailRule(rule: PaperlessMailRule = null) {
const modal = this.modalService.open(MailRuleEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = rule
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.object = rule
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newMailRule) => {
this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`)
this.mailRuleService.clearCache()
this.mailRuleService
.listAll(null, null, { full_perms: true })
.subscribe((r) => {
this.mailRules = r.results
})
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving rule.`, e)
})
}
deleteMailRule(rule: PaperlessMailRule) {
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete mail rule`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail rule.`
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.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail rule`)
this.mailRuleService.clearCache()
this.mailRuleService
.listAll(null, null, { full_perms: true })
.subscribe((r) => {
this.mailRules = r.results
})
},
error: (e) => {
this.toastService.showError($localize`Error deleting mail rule.`, e)
},
})
})
}
editPermissions(object: PaperlessMailRule | PaperlessMailAccount) {
const modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
})
const dialog: PermissionsDialogComponent =
modal.componentInstance as PermissionsDialogComponent
dialog.object = object
modal.componentInstance.confirmClicked.subscribe((permissions) => {
modal.componentInstance.buttonsEnabled = false
const service: AbstractPaperlessService<
PaperlessMailRule | PaperlessMailAccount
> = 'account' in object ? this.mailRuleService : this.mailAccountService
object.owner = permissions['owner']
object['set_permissions'] = permissions['set_permissions']
service.patch(object).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
modal.close()
},
error: (e) => {
this.toastService.showError($localize`Error updating permissions`, e)
},
})
})
}
userCanEdit(obj: ObjectWithPermissions): boolean {
return this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
obj
)
}
userIsOwner(obj: ObjectWithPermissions): boolean {
return this.permissionsService.currentUserOwnsObject(obj)
}
}

View File

@@ -9,7 +9,12 @@
<use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" />
</svg>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n>Create</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n>
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
Create
</button>
</pngx-page-header>
<div class="row mb-3">
@@ -23,20 +28,21 @@
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
<table class="table table-striped align-middle border shadow-sm">
<div class="card border mb-3">
<table class="table table-striped align-middle shadow-sm mb-0">
<thead>
<tr>
<th scope="col">
<div class="form-check">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
<th scope="col" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
<th scope="col" class="d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
<th scope="col" *ngFor="let column of extraColumns" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
<th scope="col" i18n>Actions</th>
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
<th scope="col" class="fw-normal" *ngFor="let column of extraColumns" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
<th scope="col" class="fw-normal" i18n>Actions</th>
</tr>
</thead>
<tbody>
@@ -48,12 +54,12 @@
</tr>
<tr *ngFor="let object of data" (click)="toggleSelected(object, $event); $event.stopPropagation();">
<td>
<div class="form-check">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
</div>
</td>
<td scope="row">{{ object.name }}</td>
<td scope="row"><button class="btn btn-link ms-0 ps-0" (click)="openEditDialog(object)">{{ object.name }}</button> </td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ object.document_count }}</td>
<td scope="row" *ngFor="let column of extraColumns">
@@ -96,6 +102,7 @@
</tr>
</tbody>
</table>
</div>
<div class="d-flex mb-2" *ngIf="!isLoading">
<div *ngIf="collectionSize > 0">

View File

@@ -2,3 +2,11 @@
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}
tbody tr:last-child td {
border-bottom: none !important;
}
.form-check {
min-height: 0;
}

View File

@@ -172,7 +172,7 @@ describe('ManagementListComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reloadData')
const editButton = fixture.debugElement.queryAll(By.css('button'))[5]
const editButton = fixture.debugElement.queryAll(By.css('button'))[6]
editButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -198,7 +198,7 @@ describe('ManagementListComponent', () => {
const deleteSpy = jest.spyOn(tagService, 'delete')
const reloadSpy = jest.spyOn(component, 'reloadData')
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[6]
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[7]
deleteButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -218,7 +218,7 @@ describe('ManagementListComponent', () => {
it('should support quick filter for objects', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
const filterButton = fixture.debugElement.queryAll(By.css('button'))[4]
const filterButton = fixture.debugElement.queryAll(By.css('button'))[5]
filterButton.triggerEventHandler('click')
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },

View File

@@ -1,494 +0,0 @@
<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" (mouseover)="maybeInitializeTab(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 i18n>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>
<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" i18n-hint hint="Note that for users of third-party containers e.g. linuxserver.io this notification may be 'ahead' of the current third-party release."></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.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" (mouseover)="maybeInitializeTab(SettingsNavIDs.SavedViews)" (focusin)="maybeInitializeTab(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>&nbsp;<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>
<li *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }" [ngbNavItem]="SettingsNavIDs.Mail" (mouseover)="maybeInitializeTab(SettingsNavIDs.Mail)" (focusin)="maybeInitializeTab(SettingsNavIDs.Mail)">
<a ngbNavLink i18n>Mail</a>
<ng-template ngbNavContent>
<ng-container *ngIf="mailAccounts && mailRules">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }">
<h4>
<ng-container i18n>Mail accounts</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Account</ng-container>
</button>
</h4>
<ul class="list-group" formGroupName="mailAccounts">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Server</div>
<div class="col" i18n>Actions</div>
</div>
</li>
<li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userCanEdit(account)" class="btn btn-sm btn-primary" type="button" (click)="editMailAccount(account)" i18n>Edit</button>
<button *pngxIfOwner="account" class="btn btn-sm btn-primary" type="button" (click)="editPermissions(account)" i18n>Permissions</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)" i18n>Delete</button>
</div>
</div>
</div>
</li>
<div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div>
</ul>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }">
<h4 class="mt-4">
<ng-container i18n>Mail rules</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Rule</ng-container>
</button>
</h4>
<ul class="list-group" formGroupName="mailRules">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Account</div>
<div class="col" i18n>Actions</div>
</div>
</li>
<li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-primary" type="button" (click)="editMailRule(rule)" i18n>Edit</button>
<button *pngxIfOwner="rule" class="btn btn-sm btn-primary" type="button" (click)="editPermissions(rule)" i18n>Permissions</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)" i18n>Delete</button>
</div>
</div>
</div>
</li>
<div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div>
</ul>
</ng-container>
</ng-container>
<div *ngIf="!mailAccounts || !mailRules">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="SettingsNavIDs.UsersGroups" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)">
<a ngbNavLink i18n>Users & Groups</a>
<ng-template ngbNavContent>
<ng-container *ngIf="users && groups">
<h4 class="d-flex">
<ng-container i18n>Users</ng-container>
<button type="button" class="btn btn-sm btn-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" formGroupName="usersGroup">
<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" [formGroupName]="user.id">
<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-primary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }" i18n>Delete</button>
</div>
</div>
</div>
</li>
</ul>
<h4 class="mt-4 d-flex">
<ng-container i18n>Groups</ng-container>
<button type="button" class="btn btn-sm btn-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" formGroupName="groupsGroup">
<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" [formGroupName]="group.id">
<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-primary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }" i18n>Delete</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>
</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>

View File

@@ -1,665 +0,0 @@
import { ViewportScroller, 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 { By } from '@angular/platform-browser'
import { Router, ActivatedRoute, convertToParamMap } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbModal,
NgbModule,
NgbAlertModule,
NgbNavLink,
NgbModalRef,
} 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 { PaperlessMailAccount } from 'src/app/data/paperless-mail-account'
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
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 { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.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 { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-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 { ColorComponent } from '../../common/input/color/color.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.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
const savedViews = [
{ id: 1, name: 'view1' },
{ id: 2, name: 'view2' },
]
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' },
]
const mailAccounts = [
{ id: 1, name: 'account1' },
{ id: 2, name: 'account2' },
]
const mailRules = [
{ id: 1, name: 'rule1', owner: 1, account: 1 },
{ id: 2, name: 'rule2', owner: 2, account: 2 },
]
describe('SettingsComponent', () => {
let component: SettingsComponent
let fixture: ComponentFixture<SettingsComponent>
let modalService: NgbModal
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
let mailAccountService: MailAccountService
let mailRuleService: MailRuleService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
SettingsComponent,
PageHeaderComponent,
IfPermissionsDirective,
CustomDatePipe,
ConfirmDialogComponent,
CheckComponent,
ColorComponent,
SafeHtmlPipe,
SelectComponent,
TextComponent,
PasswordComponent,
NumberComponent,
TagsComponent,
MailAccountEditDialogComponent,
MailRuleEditDialogComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
IfOwnerDirective,
PermissionsDialogComponent,
PermissionsFormComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
NgbModule,
HttpClientTestingModule,
RouterTestingModule.withRoutes(routes),
FormsModule,
ReactiveFormsModule,
NgbAlertModule,
NgSelectModule,
],
}).compileComponents()
modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router)
activatedRoute = TestBed.inject(ActivatedRoute)
viewportScroller = TestBed.inject(ViewportScroller)
toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
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)
mailAccountService = TestBed.inject(MailAccountService)
mailRuleService = TestBed.inject(MailRuleService)
})
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([]),
})
)
}
if (excludeService !== mailAccountService) {
jest.spyOn(mailAccountService, 'listAll').mockReturnValue(
of({
all: mailAccounts.map((a) => a.id),
count: mailAccounts.length,
results: (mailAccounts as PaperlessMailAccount[]).concat([]),
})
)
}
if (excludeService !== mailRuleService) {
jest.spyOn(mailRuleService, 'listAll').mockReturnValue(
of({
all: mailRules.map((r) => r.id),
count: mailRules.length,
results: (mailRules as PaperlessMailRule[]).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', 'notifications'])
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'savedviews'])
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'mail'])
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'usersgroups'])
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: 'mail' })))
activatedRoute.snapshot.fragment = '#mail'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
expect(component.activeNavID).toEqual(4) // Mail
component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#mail')
})
it('should lazy load tab data', () => {
completeSetup()
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
expect(component.savedViews).toBeUndefined()
tabButtons[2].nativeElement.dispatchEvent(
new MouseEvent('mouseover', { bubbles: true })
)
expect(component.savedViews).not.toBeUndefined()
expect(component.mailAccounts).toBeUndefined()
tabButtons[3].nativeElement.dispatchEvent(
new MouseEvent('mouseover', { bubbles: true })
)
expect(component.mailAccounts).not.toBeUndefined()
expect(component.groups).toBeUndefined()
tabButtons[4].nativeElement.dispatchEvent(
new MouseEvent('mouseover', { bubbles: true })
)
expect(component.groups).not.toBeUndefined()
})
it('should support save saved views, show error', () => {
completeSetup()
component.maybeInitializeTab(3) // SavedViews
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSpy = jest.spyOn(toastService, 'show')
const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany')
// 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 support save local settings updating appearance settings and calling API, show error', () => {
completeSetup()
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()
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()
component.maybeInitializeTab(3) // SavedViews
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 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}".`
)
})
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}".`
)
})
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()
component.maybeInitializeTab(5) // UsersGroups
expect(component.getGroupName(1)).toEqual(groups[0].name)
expect(component.getGroupName(11)).toEqual('')
})
it('should show errors on load if load mailAccounts failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailAccountService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail accounts'))
)
completeSetup(mailAccountService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should show errors on load if load mailRules failure', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(mailRuleService, 'listAll')
.mockImplementation(() =>
throwError(() => new Error('failed to load mail rules'))
)
completeSetup(mailRuleService)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab
fixture.detectChanges()
// tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(toastErrorSpy).toBeCalled()
})
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)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
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)
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
it('should support edit / create mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailAccount(mailAccounts[0] as PaperlessMailAccount)
const editDialog = modal.componentInstance as MailAccountEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
editDialog.succeeded.emit(mailAccounts[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved account "${mailAccounts[0].name}".`
)
})
it('should support delete mail account, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount)
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailAccountService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail account'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account')
})
it('should support edit / create mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editMailRule(mailRules[0] as PaperlessMailRule)
const editDialog = modal.componentInstance as MailRuleEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
editDialog.succeeded.emit(mailRules[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved rule "${mailRules[0].name}".`
)
})
it('should support delete mail rule, show error if needed', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteMailRule(mailRules[0] as PaperlessMailRule)
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(mailRuleService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule')
})
it('should support edit permissions on mail rule objects', () => {
completeSetup()
const perms = {
owner: 99,
set_permissions: {
view: {
users: [1],
groups: [2],
},
change: {
users: [3],
groups: [4],
},
},
}
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
component.editPermissions(mailRules[0] as PaperlessMailRule)
expect(modal).not.toBeUndefined()
let dialog = modal.componentInstance as PermissionsDialogComponent
expect(dialog.object).toEqual(mailRules[0])
rulePatchSpy.mockReturnValueOnce(
throwError(() => new Error('error saving perms'))
)
dialog.confirmClicked.emit(perms)
expect(rulePatchSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
rulePatchSpy.mockReturnValueOnce(of(mailRules[0] as PaperlessMailRule))
dialog.confirmClicked.emit(perms)
expect(toastInfoSpy).toHaveBeenCalledWith('Permissions updated')
modalService.dismissAll()
})
it('should support edit permissions on mail account objects', () => {
completeSetup()
const perms = {
owner: 99,
set_permissions: {
view: {
users: [1],
groups: [2],
},
change: {
users: [3],
groups: [4],
},
},
}
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
const accountPatchSpy = jest.spyOn(mailAccountService, 'patch')
component.editPermissions(mailAccounts[0] as PaperlessMailAccount)
expect(modal).not.toBeUndefined()
let dialog = modal.componentInstance as PermissionsDialogComponent
expect(dialog.object).toEqual(mailAccounts[0])
dialog = modal.componentInstance as PermissionsDialogComponent
dialog.confirmClicked.emit(perms)
expect(accountPatchSpy).toHaveBeenCalled()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,134 +0,0 @@
<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>&nbsp;<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>&nbsp;<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>&nbsp;<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 }}&hellip;</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">&hellip;</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>&nbsp;<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>&nbsp;<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>

View File

@@ -1,28 +0,0 @@
::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;
}
}

View File

@@ -1,272 +0,0 @@
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,
])
})
})

View File

@@ -1,139 +0,0 @@
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`
}
}
}