Chorehancement: update to Angular v22, 'zoneless' / 'reactive' (#13114)

This commit is contained in:
shamoon
2026-07-10 00:42:16 -07:00
committed by GitHub
parent f244442c65
commit 106b41a15c
213 changed files with 5363 additions and 5842 deletions
@@ -1,10 +1,10 @@
@if (active) {
@if (active()) {
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
@if (!isNumbered && selected) {
@if (!isNumbered() && selected()) {
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
}
@if (isNumbered) {
<div class="number">{{number}}<span class="visually-hidden">selected</span></div>
@if (isNumbered()) {
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div>
}
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
</button>
@@ -21,20 +21,20 @@ describe('ClearableBadgeComponent', () => {
})
it('should support selected', () => {
component.selected = true
expect(component.active).toBeTruthy()
fixture.componentRef.setInput('selected', true)
expect(component.active()).toBeTruthy()
})
it('should support numbered', () => {
component.number = 3
fixture.componentRef.setInput('number', 3)
fixture.detectChanges()
expect(component.active).toBeTruthy()
expect(component.active()).toBeTruthy()
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
})
it('should support selected', () => {
let clearedResult
component.selected = true
fixture.componentRef.setInput('selected', true)
fixture.detectChanges()
component.cleared.subscribe((clear) => {
clearedResult = clear
@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { Component, computed, EventEmitter, input, Output } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@Component({
@@ -8,24 +8,15 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
imports: [NgxBootstrapIconsModule],
})
export class ClearableBadgeComponent {
constructor() {}
@Input()
number: number
@Input()
selected: boolean
readonly number = input<number>(undefined)
readonly selected = input<boolean>(undefined)
@Output()
cleared: EventEmitter<boolean> = new EventEmitter()
get active(): boolean {
return this.selected || this.number > -1
}
readonly active = computed(() => this.selected() || this.number() > -1)
get isNumbered(): boolean {
return this.number > -1
}
readonly isNumbered = computed(() => this.number() > -1)
onClick(event: PointerEvent) {
this.cleared.emit(true)
@@ -23,10 +23,10 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
title = $localize`Confirmation`
@Input()
messageBold
messageBold: string
@Input()
message
message: string
@Input()
btnClass = 'btn-primary'
@@ -38,7 +38,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
alternativeBtnClass = 'btn-secondary'
@Input()
alternativeBtnCaption
alternativeBtnCaption: string
@Input()
cancelBtnClass = 'btn-outline-secondary'
@@ -9,9 +9,9 @@
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
<ul class="list-group"
cdkDropList
[cdkDropListData]="documentIDs"
[cdkDropListData]="documentIDs()"
(cdkDropListDropped)="onDrop($event)">
@for (documentID of documentIDs; track documentID) {
@for (documentID of documentIDs(); track documentID) {
@let document = getDocument(documentID);
@if (document) {
<li class="list-group-item d-flex align-items-center" cdkDrag>
@@ -36,22 +36,22 @@
</div>
<div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select class="form-select" [(ngModel)]="metadataDocumentID">
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents; track document.id) {
@for (document of documents(); track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback">
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [ngModel]="archiveFallback()" (ngModelChange)="archiveFallback.set($event)">
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [ngModel]="deleteOriginals()" (ngModelChange)="deleteOriginals.set($event)" [disabled]="!userOwnsAllDocuments">
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
</div>
@if (!archiveFallback) {
@if (!archiveFallback()) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
}
</div>
@@ -50,12 +50,12 @@ describe('MergeConfirmDialogComponent', () => {
component.ngOnInit()
expect(component.documents).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
expect(component.documents()).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs())
})
it('should move documentIDs on drop', () => {
component.documentIDs = [1, 2, 3]
component.documentIDs.set([1, 2, 3])
const event = {
previousIndex: 1,
currentIndex: 2,
@@ -63,7 +63,7 @@ describe('MergeConfirmDialogComponent', () => {
component.onDrop(event as any)
expect(component.documentIDs).toEqual([1, 3, 2])
expect(component.documentIDs()).toEqual([1, 3, 2])
})
it('should get document by ID', () => {
@@ -4,7 +4,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { AsyncPipe } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
@@ -36,15 +36,11 @@ export class MergeConfirmDialogComponent
private documentService = inject(DocumentService)
private permissionService = inject(PermissionsService)
public documentIDs: number[] = []
public archiveFallback: boolean = false
public deleteOriginals: boolean = false
private _documents: Document[] = []
get documents(): Document[] {
return this._documents
}
public metadataDocumentID: number = -1
readonly documentIDs = signal<number[]>([])
readonly archiveFallback = signal(false)
readonly deleteOriginals = signal(false)
readonly documents = signal<Document[]>([])
readonly metadataDocumentID = signal(-1)
constructor() {
super()
@@ -52,23 +48,25 @@ export class MergeConfirmDialogComponent
ngOnInit() {
this.documentService
.getFew(this.documentIDs)
.getFew(this.documentIDs())
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this._documents = r.results
this.documents.set(r.results)
})
}
onDrop(event: CdkDragDrop<number[]>) {
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
const documentIDs = this.documentIDs().concat()
moveItemInArray(documentIDs, event.previousIndex, event.currentIndex)
this.documentIDs.set(documentIDs)
}
getDocument(documentID: number): Document {
return this.documents.find((d) => d.id === documentID)
return this.documents().find((d) => d.id === documentID)
}
get userOwnsAllDocuments(): boolean {
return this.documents.every((d) =>
return this.documents().every((d) =>
this.permissionService.currentUserOwnsObject(d)
)
}
@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core'
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -14,18 +14,11 @@ export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponen
includeMetadata: boolean = true
deleteOriginal: boolean = false
@Input()
override title = $localize`Remove password protection`
@Input()
override message =
$localize`Create an unprotected copy or replace the existing file.`
@Input()
override btnCaption = $localize`Start`
constructor() {
super()
this.title = $localize`Remove password protection`
this.message = $localize`Create an unprotected copy or replace the existing file.`
this.btnCaption = $localize`Start`
}
onUpdateDocumentChange(updateDocument: boolean) {
@@ -11,8 +11,8 @@
</button>
</div>
<div class="col-8 d-flex align-items-center">
@if (documentID) {
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
@if (documentID()) {
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation()+'deg)'}" [src]="documentService.getThumbUrl(documentID())" />
}
</div>
<div class="col-2 d-flex">
@@ -21,7 +21,7 @@
</button>
</div>
</div>
@if (showPDFNote) {
@if (showPDFNote()) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
}
</div>
@@ -28,7 +28,7 @@ describe('RotateConfirmDialogComponent', () => {
})
it('should support rotating the image', () => {
component.documentID = 1
component.documentID.set(1)
fixture.detectChanges()
component.rotate()
fixture.detectChanges()
@@ -1,5 +1,5 @@
import { NgStyle } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -13,14 +13,12 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
documentService = inject(DocumentService)
public documentID: number
public showPDFNote: boolean = true
// animation is better if we dont normalize yet
public rotation: number = 0
readonly documentID = signal<number>(undefined)
readonly showPDFNote = signal(true)
readonly rotation = signal(0)
public get degrees(): number {
let degrees = this.rotation % 360
let degrees = this.rotation() % 360
if (degrees < 0) degrees += 360
return degrees
}
@@ -30,6 +28,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
}
rotate(clockwise: boolean = true) {
this.rotation += clockwise ? 90 : -90
this.rotation.update((rotation) => rotation + (clockwise ? 90 : -90))
}
}
@@ -1,11 +1,6 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import {
@@ -74,7 +69,7 @@ describe('CustomFieldsDropdownComponent', () => {
})
)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 1, username: 'test' }
settingsService.currentUser.set({ id: 1, username: 'test' })
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -110,7 +105,8 @@ describe('CustomFieldsDropdownComponent', () => {
)
})
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
it('should support creating field, show error if necessary, then add', () => {
jest.useFakeTimers()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -134,11 +130,12 @@ describe('CustomFieldsDropdownComponent', () => {
// succeed
editDialog.succeeded.emit(fields[0])
tick(100)
jest.advanceTimersByTime(100)
expect(toastInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled()
expect(addFieldSpy).toHaveBeenCalled()
}))
jest.useRealTimers()
})
it('should support creating field with name', () => {
let modal: NgbModalRef
@@ -150,12 +147,13 @@ describe('CustomFieldsDropdownComponent', () => {
expect(editDialog.object.name).toEqual('Foo bar')
})
it('should support arrow keyboard navigation', fakeAsync(() => {
it('should support arrow keyboard navigation', () => {
jest.useFakeTimers()
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
jest.advanceTimersByTime(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -193,14 +191,16 @@ describe('CustomFieldsDropdownComponent', () => {
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
}))
jest.useRealTimers()
})
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
it('should support arrow keyboard navigation after tab keyboard navigation', () => {
jest.useFakeTimers()
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
jest.advanceTimersByTime(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -229,9 +229,10 @@ describe('CustomFieldsDropdownComponent', () => {
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
}))
jest.useRealTimers()
})
it('should support enter keyboard navigation', fakeAsync(() => {
it('should support enter keyboard navigation', () => {
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
const addFieldSpy = jest.spyOn(component, 'addField')
const createFieldSpy = jest.spyOn(component, 'createField')
@@ -250,5 +251,5 @@ describe('CustomFieldsDropdownComponent', () => {
component.listFilterEnter()
expect(createFieldSpy).not.toHaveBeenCalled()
expect(addFieldSpy).not.toHaveBeenCalled()
}))
})
})
@@ -75,7 +75,7 @@
<ng-template #queryAtom let-atom="atom">
<div class="input-group input-group-sm">
<ng-select
<ng-select #fieldSelects
class="paperless-input-select"
[items]="customFields"
[(ngModel)]="atom.field"
@@ -1,11 +1,6 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
@@ -210,14 +205,13 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(component.name).toBe('test_title')
})
it('should add a default atom on open and focus the select field', fakeAsync(() => {
it('should add a default atom on open', async () => {
expect(component.selectionModel.queries.length).toBe(0)
component.onOpenChange(true)
fixture.detectChanges()
tick()
await fixture.whenStable()
expect(component.selectionModel.queries.length).toBe(1)
expect(window.document.activeElement.tagName).toBe('INPUT')
}))
})
describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel
@@ -1,12 +1,7 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
@@ -59,27 +54,32 @@ describe('DatesDropdownComponent', () => {
expect(settingsSpy).toHaveBeenCalled()
})
it('should support date input, emit change', fakeAsync(() => {
it('should support date input, emit change', () => {
jest.useFakeTimers()
let result: string
component.createdDateFromChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('change'))
tick(500)
jest.advanceTimersByTime(500)
expect(result).not.toBeNull()
}))
jest.useRealTimers()
})
it('should support date select, emit datesSet change', fakeAsync(() => {
it('should support date select, emit datesSet change', () => {
jest.useFakeTimers()
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('dateSelect'))
tick(500)
jest.advanceTimersByTime(500)
expect(result).not.toBeNull()
}))
jest.useRealTimers()
})
it('should support relative dates', fakeAsync(() => {
it('should support relative dates', () => {
jest.useFakeTimers()
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
@@ -88,7 +88,7 @@ describe('DatesDropdownComponent', () => {
} as any)
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
tick(500)
jest.advanceTimersByTime(500)
expect(result).toEqual({
createdFrom: null,
createdTo: null,
@@ -97,7 +97,8 @@ describe('DatesDropdownComponent', () => {
addedTo: null,
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
})
}))
jest.useRealTimers()
})
it('should support report if active', () => {
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
@@ -177,11 +178,12 @@ describe('DatesDropdownComponent', () => {
expect(eventSpy).toHaveBeenCalled()
})
it('should support debounce', fakeAsync(() => {
it('should support debounce', () => {
jest.useFakeTimers()
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.onChangeDebounce()
tick(500)
jest.advanceTimersByTime(500)
expect(result).toEqual({
createdFrom: null,
createdTo: null,
@@ -190,5 +192,6 @@ describe('DatesDropdownComponent', () => {
addedTo: null,
addedRelativeDateID: null,
})
}))
jest.useRealTimers()
})
})
@@ -41,20 +41,20 @@ describe('CorrespondentEditDialogComponent', () => {
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -44,33 +44,33 @@ describe('CustomFieldEditDialogComponent', () => {
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should disable data type select on edit', () => {
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
component.ngOnInit()
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
})
it('should initialize select options on edit', () => {
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
component.object = {
id: 1,
name: 'Field 1',
@@ -91,7 +91,7 @@ describe('CustomFieldEditDialogComponent', () => {
})
it('should support add / remove select options', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
fixture.detectChanges()
component.ngOnInit()
expect(
@@ -115,7 +115,7 @@ describe('CustomFieldEditDialogComponent', () => {
const selectOptionInputs = component[
'selectOptionInputs'
] as QueryList<ElementRef>
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
component.objectForm.get('data_type').setValue(CustomFieldDataType.Select)
component.ngOnInit()
component.ngAfterViewInit()
@@ -125,7 +125,7 @@ describe('CustomFieldEditDialogComponent', () => {
})
it('should send all select options including those changed in form on save', () => {
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
component.object = {
id: 1,
name: 'Field 1',
@@ -149,7 +149,7 @@ export class CustomFieldEditDialogComponent
}
get typeFieldDisabled(): boolean {
return this.dialogMode === EditDialogMode.EDIT
return this.dialogMode() === EditDialogMode.EDIT
}
private updateSelectOptions() {
@@ -41,20 +41,20 @@ describe('DocumentTypeEditDialogComponent', () => {
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -4,12 +4,7 @@ import {
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { Component } from '@angular/core'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormControl,
FormGroup,
@@ -122,7 +117,7 @@ describe('EditDialogComponent', () => {
tagService = TestBed.inject(TagService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = currentUser
settingsService.currentUser.set(currentUser as any)
permissionsService.initialize([], currentUser as any)
activeModal = TestBed.inject(NgbActiveModal)
httpTestingController = TestBed.inject(HttpTestingController)
@@ -136,7 +131,7 @@ describe('EditDialogComponent', () => {
it('should interpolate object permissions', () => {
component.getMatchingAlgorithms() // coverage
component.object = tag
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
component.ngOnInit()
expect(component.objectForm.get('permissions_form').value).toEqual({
@@ -145,15 +140,17 @@ describe('EditDialogComponent', () => {
})
})
it('should delay close enabled', fakeAsync(() => {
it('should delay close enabled', () => {
jest.useFakeTimers()
expect(component.closeEnabled).toBeFalsy()
component.ngOnInit()
tick(100)
jest.advanceTimersByTime(100)
expect(component.closeEnabled).toBeTruthy()
}))
jest.useRealTimers()
})
it('should set default owner when in create mode if unset', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
component.ngOnInit()
expect(component.objectForm.get('permissions_form').value.owner).toEqual(
currentUser.id
@@ -164,7 +161,7 @@ describe('EditDialogComponent', () => {
})
it('should set default perms when in create mode if set', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_OWNER, 11)
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2])
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3])
@@ -203,18 +200,18 @@ describe('EditDialogComponent', () => {
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
component.getTitle()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
component.dialogMode.set(EditDialogMode.EDIT)
component.getTitle()
expect(editTitleSpy).toHaveBeenCalled()
// coverage
component.dialogMode = null
fixture.detectChanges()
component.dialogMode.set(null)
component.getTitle()
})
it('should close on cancel', () => {
@@ -225,14 +222,14 @@ describe('EditDialogComponent', () => {
it('should update an object on save in edit mode', () => {
const updateSpy = jest.spyOn(tagService, 'update')
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
component.save()
expect(updateSpy).toHaveBeenCalled()
})
it('should not submit owner or permissions for non-owner edits', () => {
component.object = tag
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
component.ngOnInit()
component.objectForm.get('name').setValue('Updated tag')
@@ -251,7 +248,7 @@ describe('EditDialogComponent', () => {
it('should create an object on save in edit mode', () => {
const createSpy = jest.spyOn(tagService, 'create')
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
component.save()
expect(createSpy).toHaveBeenCalled()
})
@@ -5,6 +5,7 @@ import {
OnInit,
Output,
inject,
model,
} from '@angular/core'
import { FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
@@ -45,10 +46,7 @@ export abstract class EditDialogComponent<
protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService)
users: User[]
@Input()
dialogMode: EditDialogMode = EditDialogMode.CREATE
dialogMode = model(EditDialogMode.CREATE)
@Input()
object: T
@@ -59,18 +57,20 @@ export abstract class EditDialogComponent<
@Output()
failed = new EventEmitter()
users: User[]
networkActive = false
closeEnabled = false
error = null
error: any = null
abstract getForm(): FormGroup
objectForm: FormGroup = this.getForm()
ngOnInit(): void {
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
if (this.object != null && this.dialogMode() !== EditDialogMode.CREATE) {
this.object['permissions_form'] = {
owner: (this.object as ObjectWithPermissions).owner,
set_permissions: (this.object as ObjectWithPermissions).permissions,
@@ -124,7 +124,7 @@ export abstract class EditDialogComponent<
}
getTitle() {
switch (this.dialogMode) {
switch (this.dialogMode()) {
case EditDialogMode.CREATE:
return this.getCreateTitle()
case EditDialogMode.EDIT:
@@ -151,7 +151,7 @@ export abstract class EditDialogComponent<
protected shouldSubmitPermissions(): boolean {
return (
this.dialogMode === EditDialogMode.CREATE ||
this.dialogMode() === EditDialogMode.CREATE ||
this.permissionsService.currentUserOwnsObject(this.object)
)
}
@@ -172,7 +172,7 @@ export abstract class EditDialogComponent<
delete newObject['set_permissions']
}
var serverResponse: Observable<T>
switch (this.dialogMode) {
switch (this.dialogMode()) {
case EditDialogMode.CREATE:
serverResponse = this.service.create(newObject)
break
@@ -45,20 +45,20 @@ describe('GroupEditDialogComponent', () => {
fixture = TestBed.createComponent(GroupEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -25,8 +25,8 @@
</div>
<div class="modal-footer">
<div class="m-0 me-2">
@if (testResult) {
<ngb-alert #testResultAlert [type]="testResult" class="mb-0 py-2" (closed)="testResult = null">{{testResultMessage}}</ngb-alert>
@if (testResult()) {
<ngb-alert #testResultAlert [type]="testResult()" class="mb-0 py-2" (closed)="testResult.set(null)">{{testResultMessage}}</ngb-alert>
}
</div>
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
@@ -3,12 +3,7 @@ import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
@@ -58,25 +53,26 @@ describe('MailAccountEditDialogComponent', () => {
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should support test mail account and show appropriate expiring alert', fakeAsync(() => {
it('should support test mail account and show appropriate expiring alert', () => {
jest.useFakeTimers()
component.object = {
name: 'example',
imap_server: 'imap.example.com',
@@ -97,7 +93,7 @@ describe('MailAccountEditDialogComponent', () => {
expect(fixture.nativeElement.textContent).toContain(
'Successfully connected'
)
tick(6000)
jest.advanceTimersByTime(6000)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain(
'Successfully connected'
@@ -118,6 +114,7 @@ describe('MailAccountEditDialogComponent', () => {
.flush({}, { status: 500, statusText: 'error' })
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
tick(6000)
}))
jest.advanceTimersByTime(6000)
jest.useRealTimers()
})
})
@@ -1,4 +1,4 @@
import { Component, ViewChild, inject } from '@angular/core'
import { Component, ViewChild, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -38,7 +38,7 @@ const IMAP_SECURITY_OPTIONS = [
})
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
testActive: boolean = false
testResult: string
readonly testResult = signal<string>(undefined)
alertTimeout
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
@@ -77,7 +77,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
test() {
this.testActive = true
this.testResult = null
this.testResult.set(null)
clearTimeout(this.alertTimeout)
const mailService = this.service as MailAccountService
const newObject = Object.assign(
@@ -87,19 +87,19 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
mailService.test(newObject).subscribe({
next: (result: { success: boolean }) => {
this.testActive = false
this.testResult = result.success ? 'success' : 'danger'
this.testResult.set(result.success ? 'success' : 'danger')
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
},
error: (e) => {
this.testActive = false
this.testResult = 'danger'
this.testResult.set('danger')
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
},
})
}
get testResultMessage() {
return this.testResult === 'success'
return this.testResult() === 'success'
? $localize`Successfully connected to the mail server`
: $localize`Unable to connect to the mail server`
}
@@ -13,7 +13,7 @@
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
</div>
<div class="col-md-4">
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts()" formControlName="account"></pngx-input-select>
</div>
<div class="col-md-2 pt-2">
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
@@ -65,10 +65,10 @@
</div>
<div class="col-md-6">
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes()" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
@if (showCorrespondentField) {
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents()" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
}
</div>
</div>
@@ -75,20 +75,20 @@ describe('MailRuleEditDialogComponent', () => {
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -1,11 +1,12 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { first } from 'rxjs'
import { map } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
@@ -154,37 +155,28 @@ const METADATA_CORRESPONDENT_OPTIONS = [
],
})
export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
private accountService: MailAccountService
private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService
private readonly accountService = inject(MailAccountService)
private readonly correspondentService = inject(CorrespondentService)
private readonly documentTypeService = inject(DocumentTypeService)
accounts: MailAccount[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
readonly accounts = toSignal(
this.accountService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as MailAccount[] }
)
readonly correspondents = toSignal(
this.correspondentService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as Correspondent[] }
)
readonly documentTypes = toSignal(
this.documentTypeService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as DocumentType[] }
)
constructor() {
super()
this.service = inject(MailRuleService)
this.accountService = inject(MailAccountService)
this.correspondentService = inject(CorrespondentService)
this.documentTypeService = inject(DocumentTypeService)
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
this.accountService
.listAll()
.pipe(first())
.subscribe((result) => (this.accounts = result.results))
this.correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
}
getCreateTitle() {
@@ -30,20 +30,20 @@ describe('StoragePathEditDialogComponent', () => {
documentService = TestBed.inject(DocumentService)
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -67,11 +67,11 @@ export class StoragePathEditDialogComponent
private testDocument: Document
public testResult: string
public testFailed: boolean = false
public loading = false
public testLoading = false
constructor() {
super()
this.loading.set(false)
this.service = inject(StoragePathService)
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
@@ -138,7 +138,7 @@ export class StoragePathEditDialogComponent
this.documentsInput$.pipe(
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier),
tap(() => (this.loading = true)),
tap(() => this.loading.set(true)),
switchMap((title) =>
this.documentsService
.listFiltered(
@@ -152,7 +152,7 @@ export class StoragePathEditDialogComponent
.pipe(
map((result) => result.results),
catchError(() => of([])), // empty on error
tap(() => (this.loading = false))
tap(() => this.loading.set(false))
)
)
)
@@ -48,20 +48,20 @@ describe('TagEditDialogComponent', () => {
fixture = TestBed.createComponent(TagEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -31,7 +31,7 @@
</div>
</div>
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
<pngx-input-select i18n-title title="Groups" [items]="groups()" multiple="true" formControlName="groups"></pngx-input-select>
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
<label class="form-label" i18n>Two-factor Authentication</label>
@@ -42,7 +42,7 @@
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
[disabled]="totpLoading()"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
@@ -73,7 +73,7 @@ describe('UserEditDialogComponent', () => {
fixture = TestBed.createComponent(UserEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
permissionsService = TestBed.inject(PermissionsService)
toastService = TestBed.inject(ToastService)
component = fixture.componentInstance
@@ -82,13 +82,13 @@ describe('UserEditDialogComponent', () => {
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -114,17 +114,17 @@ describe('UserEditDialogComponent', () => {
it('should detect whether password was changed in form on save', () => {
component.objectForm.get('password').setValue(null)
component.save()
expect(component.passwordIsSet).toBeFalsy()
expect(component.passwordIsSet()).toBeFalsy()
// unchanged pw
component.objectForm.get('password').setValue('*******')
component.save()
expect(component.passwordIsSet).toBeFalsy()
expect(component.passwordIsSet()).toBeFalsy()
// unchanged pw
component.objectForm.get('password').setValue('helloworld')
component.save()
expect(component.passwordIsSet).toBeTruthy()
expect(component.passwordIsSet()).toBeTruthy()
})
it('should support deactivation of TOTP', () => {
@@ -1,11 +1,12 @@
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { first } from 'rxjs'
import { first, map } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
@@ -38,22 +39,19 @@ export class UserEditDialogComponent
implements OnInit
{
private toastService = inject(ToastService)
private groupsService: GroupService
private readonly groupsService = inject(GroupService)
groups: Group[]
passwordIsSet: boolean = false
public totpLoading: boolean = false
readonly groups = toSignal(
this.groupsService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as Group[] }
)
readonly passwordIsSet = signal(false)
readonly totpLoading = signal(false)
constructor() {
super()
this.service = inject(UserService)
this.groupsService = inject(GroupService)
this.settingsService = inject(SettingsService)
this.groupsService
.listAll()
.pipe(first())
.subscribe((result) => (this.groups = result.results))
}
ngOnInit(): void {
@@ -103,14 +101,15 @@ export class UserEditDialogComponent
if (!groupsVal) return []
else
return groupsVal.flatMap(
(id) => this.groups.find((g) => g.id == id)?.permissions
(id) => this.groups()?.find((g) => g.id == id)?.permissions
)
}
save(): void {
this.passwordIsSet =
this.passwordIsSet.set(
this.objectForm.get('password').value?.toString().replaceAll('*', '')
.length > 0
)
super.save()
}
@@ -119,13 +118,13 @@ export class UserEditDialogComponent
}
deactivateTotp() {
this.totpLoading = true
this.totpLoading.set(true)
;(this.service as UserService)
.deactivateTotp(this.object)
.pipe(first())
.subscribe({
next: (result) => {
this.totpLoading = false
this.totpLoading.set(false)
if (result) {
this.toastService.showInfo($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
@@ -134,7 +133,7 @@ export class UserEditDialogComponent
}
},
error: (e) => {
this.totpLoading = false
this.totpLoading.set(false)
this.toastService.showError($localize`Totp deactivation failed`, e)
},
})
@@ -140,7 +140,7 @@
</div>
@if (formGroup.get('schedule_date_field').value === 'custom_field') {
<div class="col-4">
<pngx-input-select i18n-title title="Custom field" formControlName="schedule_date_custom_field" [items]="dateCustomFields" i18n-hint hint="Custom field to use for date." [error]="error?.schedule_date_custom_field"></pngx-input-select>
<pngx-input-select i18n-title title="Custom field" formControlName="schedule_date_custom_field" [items]="dateCustomFields()" i18n-hint hint="Custom field to use for date." [error]="error?.schedule_date_custom_field"></pngx-input-select>
</div>
}
</div>
@@ -162,7 +162,7 @@
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@@ -262,10 +262,10 @@
<div class="col">
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes()" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents()" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths()" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields()" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
<pngx-input-custom-fields-values formControlName="assign_custom_fields_values" [selectedFields]="formGroup.get('assign_custom_fields').value" (removeSelectedField)="removeSelectedCustomField($event, formGroup)"></pngx-input-custom-fields-values>
</div>
<div class="col">
@@ -326,25 +326,25 @@
<h6 class="form-label" i18n>Remove correspondents</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_correspondents"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="correspondents" formControlName="remove_correspondents"></pngx-input-select>
<pngx-input-select i18n-title title="" multiple="true" [items]="correspondents()" formControlName="remove_correspondents"></pngx-input-select>
</div>
<h6 class="form-label" i18n>Remove document types</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_document_types"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="documentTypes" formControlName="remove_document_types"></pngx-input-select>
<pngx-input-select i18n-title title="" multiple="true" [items]="documentTypes()" formControlName="remove_document_types"></pngx-input-select>
</div>
<h6 class="form-label" i18n>Remove storage paths</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_storage_paths"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="storagePaths" formControlName="remove_storage_paths"></pngx-input-select>
<pngx-input-select i18n-title title="" multiple="true" [items]="storagePaths()" formControlName="remove_storage_paths"></pngx-input-select>
</div>
<h6 class="form-label" i18n>Remove custom fields</h6>
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_custom_fields"></pngx-input-switch>
<div class="mt-n3">
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields" formControlName="remove_custom_fields"></pngx-input-select>
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields()" formControlName="remove_custom_fields"></pngx-input-select>
</div>
</div>
<div class="col">
@@ -187,14 +187,14 @@ describe('WorkflowEditDialogComponent', () => {
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
settingsService.currentUser.set({ id: 99, username: 'user99' })
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
component.dialogMode = EditDialogMode.CREATE
component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
@@ -209,7 +209,7 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.object).not.toBeUndefined()
expect(component.object.triggers).toHaveLength(1)
component.dialogMode = EditDialogMode.EDIT
component.dialogMode.set(EditDialogMode.EDIT)
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -771,25 +771,21 @@ describe('WorkflowEditDialogComponent', () => {
false
)
component.correspondents = [{ id: 1, name: 'C1' } as any]
component.documentTypes = [{ id: 2, name: 'DT' } as any]
component.storagePaths = [{ id: 3, name: 'SP' } as any]
expect(
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
).toEqual(component.correspondents)
).toEqual(component.correspondents())
expect(
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
).toEqual(component.documentTypes)
).toEqual(component.documentTypes())
expect(
component.getFilterSelectItems(TriggerFilterType.DocumentTypeAny)
).toEqual(component.documentTypes)
).toEqual(component.documentTypes())
expect(
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
).toEqual(component.storagePaths)
).toEqual(component.storagePaths())
expect(
component.getFilterSelectItems(TriggerFilterType.StoragePathAny)
).toEqual(component.storagePaths)
).toEqual(component.storagePaths())
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
[]
)
@@ -4,7 +4,8 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, computed, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
AbstractControl,
FormArray,
@@ -15,7 +16,7 @@ import {
} from '@angular/forms'
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, first, takeUntil } from 'rxjs'
import { Subscription, map, takeUntil } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
@@ -470,23 +471,40 @@ export class WorkflowEditDialogComponent
public TriggerFilterType = TriggerFilterType
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService
private storagePathService: StoragePathService
private mailRuleService: MailRuleService
private customFieldsService: CustomFieldsService
private readonly correspondentService = inject(CorrespondentService)
private readonly documentTypeService = inject(DocumentTypeService)
private readonly storagePathService = inject(StoragePathService)
private readonly mailRuleService = inject(MailRuleService)
private readonly customFieldsService = inject(CustomFieldsService)
templates: Workflow[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
mailRules: MailRule[]
customFields: CustomField[]
dateCustomFields: CustomField[]
readonly templates = signal<Workflow[]>(undefined)
readonly correspondents = toSignal(
this.correspondentService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as Correspondent[] }
)
readonly documentTypes = toSignal(
this.documentTypeService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as DocumentType[] }
)
readonly storagePaths = toSignal(
this.storagePathService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as StoragePath[] }
)
readonly mailRules = toSignal(
this.mailRuleService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as MailRule[] }
)
readonly customFields = toSignal(
this.customFieldsService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as CustomField[] }
)
readonly dateCustomFields = computed(() =>
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
)
expandedItem: number = null
private allowedActionTypes = []
readonly allowedActionTypes = signal([])
private readonly triggerFilterOptionsMap = new WeakMap<
FormArray,
@@ -496,43 +514,8 @@ export class WorkflowEditDialogComponent
constructor() {
super()
this.service = inject(WorkflowService)
this.correspondentService = inject(CorrespondentService)
this.documentTypeService = inject(DocumentTypeService)
this.storagePathService = inject(StoragePathService)
this.mailRuleService = inject(MailRuleService)
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
this.customFieldsService = inject(CustomFieldsService)
this.correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
this.mailRuleService
.listAll()
.pipe(first())
.subscribe((result) => (this.mailRules = result.results))
this.customFieldsService
.listAll()
.pipe(first())
.subscribe((result) => {
this.customFields = result.results
this.dateCustomFields = this.customFields?.filter(
(f) => f.data_type === CustomFieldDataType.Date
)
})
}
getCreateTitle() {
@@ -565,11 +548,13 @@ export class WorkflowEditDialogComponent
this.checkRemovalActionFields.bind(this)
)
this.checkRemovalActionFields(this.objectForm.value)
this.allowedActionTypes = this.settingsService.get(
SETTINGS_KEYS.EMAIL_ENABLED
this.allowedActionTypes.set(
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
? WORKFLOW_ACTION_OPTIONS
: WORKFLOW_ACTION_OPTIONS.filter(
(a) => a.id !== WorkflowActionType.Email
)
)
? WORKFLOW_ACTION_OPTIONS
: WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
}
private checkRemovalActionFields(formWorkflow: Workflow) {
@@ -954,11 +939,11 @@ export class WorkflowEditDialogComponent
switch (definition.selectItems) {
case 'correspondents':
return this.correspondents
return this.correspondents()
case 'documentTypes':
return this.documentTypes
return this.documentTypes()
case 'storagePaths':
return this.storagePaths
return this.storagePaths()
default:
return []
}
@@ -1294,7 +1279,8 @@ export class WorkflowEditDialogComponent
}
get actionTypeOptions() {
return this.allowedActionTypes
this.settingsService.trackChanges()
return this.allowedActionTypes()
}
getActionTypeOptionName(type: WorkflowActionType): string {
@@ -1,8 +1,8 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i18n>{
documentIds.length,
documentIds().length,
plural,
=1 {Email Document} other {Email {{documentIds.length}} Documents}
=1 {Email Document} other {Email {{documentIds().length}} Documents}
}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
@@ -23,11 +23,11 @@
<div class="modal-footer">
<div class="input-group">
<div class="input-group-text flex-grow-1">
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion()" [ngModel]="useArchiveVersion()" (ngModelChange)="useArchiveVersion.set($event)">
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
</div>
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
@if (loading) {
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading() || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
@if (loading()) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
<ng-container i18n>Send email</ng-container>
@@ -36,23 +36,24 @@ describe('EmailDocumentDialogComponent', () => {
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
component = fixture.componentInstance
component.documentIds = [1]
component.documentIds.set([1])
fixture.detectChanges()
})
it('should set hasArchiveVersion and useArchiveVersion', () => {
expect(component.hasArchiveVersion).toBeTruthy()
expect(component.useArchiveVersion).toBeTruthy()
expect(component.hasArchiveVersion()).toBeTruthy()
expect(component.useArchiveVersion()).toBeTruthy()
component.hasArchiveVersion = false
expect(component.hasArchiveVersion).toBeFalsy()
expect(component.useArchiveVersion).toBeFalsy()
component.hasArchiveVersion.set(false)
fixture.detectChanges()
expect(component.hasArchiveVersion()).toBeFalsy()
expect(component.useArchiveVersion()).toBeFalsy()
})
it('should support sending single document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds = [1]
component.documentIds.set([1])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
component.emailMessage = 'World'
@@ -73,7 +74,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds = [1, 2, 3]
component.documentIds.set([1, 2, 3])
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
component.emailMessage = 'World'
@@ -1,4 +1,4 @@
import { Component, Input, inject } from '@angular/core'
import { Component, effect, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -17,22 +17,9 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
private documentService = inject(DocumentService)
private toastService = inject(ToastService)
@Input()
documentIds: number[]
private _hasArchiveVersion: boolean = true
@Input()
set hasArchiveVersion(value: boolean) {
this._hasArchiveVersion = value
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this._hasArchiveVersion
}
public useArchiveVersion: boolean = true
readonly documentIds = signal<number[]>(undefined)
readonly hasArchiveVersion = signal(true)
readonly useArchiveVersion = signal(true)
public emailAddress: string = ''
public emailSubject: string = ''
@@ -40,22 +27,25 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
constructor() {
super()
this.loading = false
this.loading.set(false)
effect(() => {
this.useArchiveVersion.set(this.hasArchiveVersion())
})
}
public emailDocuments() {
this.loading = true
this.loading.set(true)
this.documentService
.emailDocuments(
this.documentIds,
this.documentIds(),
this.emailAddress,
this.emailSubject,
this.emailMessage,
this.useArchiveVersion
this.useArchiveVersion()
)
.subscribe({
next: () => {
this.loading = false
this.loading.set(false)
this.emailAddress = ''
this.emailSubject = ''
this.emailMessage = ''
@@ -63,9 +53,9 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.toastService.showInfo($localize`Email sent`)
},
error: (e) => {
this.loading = false
this.loading.set(false)
const errorMessage =
this.documentIds.length > 1
this.documentIds().length > 1
? $localize`Error emailing documents`
: $localize`Error emailing document`
this.toastService.showError(errorMessage, e)
@@ -1,12 +1,7 @@
import { ScrollingModule } from '@angular/cdk/scrolling'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import {
@@ -52,6 +47,7 @@ const negativeNullItem = {
}
let selectionModel: FilterableDropdownSelectionModel
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent
@@ -255,14 +251,17 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(applyResult).toEqual({ itemsToAdd: [items[0]], itemsToRemove: [] })
})
it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
it('should focus text filter on open, support filtering, clear on close', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement
)
@@ -270,12 +269,16 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.filterText = 'Tag2'
fixture.detectChanges()
expect(component.buttonsViewport.getRenderedRange().end).toEqual(1) // filtered
component.buttonsViewport.checkViewportSize()
fixture.detectChanges()
expect(component.scrollViewportHeight).toEqual(
component.FILTERABLE_BUTTON_HEIGHT_PX
) // filtered
component.dropdown.close()
expect(component.filterText).toHaveLength(0)
}))
})
it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
expect(component.selectionModel.getSelectedItems()).toEqual([])
@@ -283,7 +286,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
component.filterText = 'Tag2'
fixture.detectChanges()
const closeSpy = jest.spyOn(component.dropdown, 'close')
@@ -291,11 +297,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
new KeyboardEvent('keyup', { key: 'Enter' })
)
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
tick(300)
await wait(300)
expect(closeSpy).toHaveBeenCalled()
}))
})
it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
it('should apply & close on enter inside filter field if 1 item remains if editing', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.editing = true
@@ -306,25 +312,31 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
component.filterText = 'Tag2'
fixture.detectChanges()
component.listFilterTextInput.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Enter' })
)
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
tick(300)
await wait(300)
expect(applyResult).toEqual({ itemsToAdd: [items[1]], itemsToRemove: [] })
}))
})
it('should support arrow keyboard navigation', fakeAsync(() => {
it('should support arrow keyboard navigation', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
@@ -362,16 +374,19 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
}))
})
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
it('should support arrow keyboard navigation after tab keyboard navigation', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
@@ -400,16 +415,19 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
}))
})
it('should support arrow keyboard navigation after click', fakeAsync(() => {
it('should support arrow keyboard navigation after click', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
@@ -427,9 +445,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
}))
})
it('should toggle logical operator', fakeAsync(() => {
it('should toggle logical operator', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.selectionModel.manyToOne = true
@@ -445,7 +463,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.modifierToggleEnabled).toBeTruthy()
const operatorButtons: HTMLInputElement[] = Array.from(
@@ -456,9 +477,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
fixture.detectChanges()
expect(selectionModel.logicalOperator).toEqual(LogicalOperator.Or)
expect(changedResult.logicalOperator).toEqual(LogicalOperator.Or)
}))
})
it('should toggle intersection include / exclude', fakeAsync(() => {
it('should toggle intersection include / exclude', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
selectionModel.set(items[0].id, ToggleableItemState.Selected)
@@ -473,7 +494,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.modifierToggleEnabled).toBeTruthy()
const intersectionButtons: HTMLInputElement[] = Array.from(
@@ -486,7 +510,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(changedResult.intersection).toEqual(Intersection.Exclude)
expect(changedResult.getSelectedItems()).toEqual([])
expect(changedResult.getExcludedItems()).toEqual(items)
}))
})
it('should update null item selection on toggleIntersection', () => {
component.selectionModel.items = items
@@ -819,7 +843,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
})
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
it('should set support create, keep open model and call createRef method', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.selectionModel = selectionModel
@@ -827,7 +851,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
component.filterText = 'Test Filter Text'
component.createRef = jest.fn()
@@ -837,9 +864,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
const openSpy = jest.spyOn(component.dropdown, 'open')
component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalled() // should keep open
}))
})
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.editing = true
@@ -849,12 +876,15 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
tick(100)
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
component.filterText = 'FooBar'
component.listFilterEnter()
expect(component.selectionModel.getSelectedItems()).toEqual([])
expect(createSpy).toHaveBeenCalled()
}))
})
it('should exclude item and trigger change event', () => {
const id = 1
@@ -1,11 +1,11 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<table class="table">
<tbody>
@for (key of hotkeys.entries(); track key[0]) {
@for (key of hotkeys().entries(); track key[0]) {
<tr>
<td>{{ key[1] }}</td>
<td class="d-flex justify-content-end">
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
const SYMBOLS = {
@@ -20,9 +20,8 @@ const SYMBOLS = {
})
export class HotkeyDialogComponent {
activeModal = inject(NgbActiveModal)
public title: string = $localize`Keyboard shortcuts`
public hotkeys: Map<string, string> = new Map()
readonly title = signal($localize`Keyboard shortcuts`)
readonly hotkeys = signal<Map<string, string>>(new Map())
public close(): void {
this.activeModal.close()
@@ -1,4 +1,5 @@
import {
ChangeDetectorRef,
Directive,
ElementRef,
EventEmitter,
@@ -6,12 +7,15 @@ import {
OnInit,
Output,
ViewChild,
inject,
} from '@angular/core'
import { ControlValueAccessor } from '@angular/forms'
import { v4 as uuidv4 } from 'uuid'
@Directive()
export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
protected readonly changeDetector = inject(ChangeDetectorRef)
@ViewChild('inputField')
inputField: ElementRef
@@ -23,6 +27,7 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
writeValue(newValue: any): void {
this.value = newValue
this.changeDetector.markForCheck()
}
registerOnChange(fn: any): void {
this.onChange = fn
@@ -32,6 +37,7 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled
this.changeDetector.markForCheck()
}
focus() {
@@ -3,7 +3,7 @@
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
clearable="true"
[items]="groups"
[items]="groups()"
multiple="true"
bindLabel="name"
bindValue="id"
@@ -1,11 +1,12 @@
import { Component, forwardRef, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { NgSelectComponent } from '@ng-select/ng-select'
import { first } from 'rxjs/operators'
import { map } from 'rxjs/operators'
import { Group } from 'src/app/data/group'
import { GroupService } from 'src/app/services/rest/group.service'
import { AbstractInputComponent } from '../../abstract-input'
@@ -24,15 +25,9 @@ import { AbstractInputComponent } from '../../abstract-input'
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
})
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
groups: Group[]
constructor() {
const groupService = inject(GroupService)
super()
groupService
.listAll()
.pipe(first())
.subscribe((result) => (this.groups = result.results))
}
private readonly groupService = inject(GroupService)
readonly groups = toSignal(
this.groupService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as Group[] }
)
}
@@ -3,7 +3,7 @@
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
clearable="true"
[items]="users"
[items]="users()"
multiple="true"
bindLabel="username"
bindValue="id"
@@ -1,11 +1,12 @@
import { Component, forwardRef, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { NgSelectComponent } from '@ng-select/ng-select'
import { first } from 'rxjs/operators'
import { map } from 'rxjs/operators'
import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service'
import { AbstractInputComponent } from '../../abstract-input'
@@ -24,15 +25,9 @@ import { AbstractInputComponent } from '../../abstract-input'
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
})
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
users: User[]
constructor() {
const userService = inject(UserService)
super()
userService
.listAll()
.pipe(first())
.subscribe((result) => (this.users = result.results))
}
private readonly userService = inject(UserService)
readonly users = toSignal(
this.userService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as User[] }
)
}
@@ -1,9 +1,4 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
@@ -121,12 +116,14 @@ describe('SelectComponent', () => {
).toBeFalsy()
})
it('should clear search term on blur after delay', fakeAsync(() => {
it('should clear search term on blur after delay', () => {
jest.useFakeTimers()
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
component.onBlur()
tick(3000)
jest.advanceTimersByTime(3000)
expect(clearSpy).toHaveBeenCalled()
}))
jest.useRealTimers()
})
it('should emit filtered documents', () => {
component.value = 10
@@ -106,7 +106,7 @@ describe('TagsComponent', () => {
modalService = TestBed.inject(NgbModal)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 1 }
settingsService.currentUser.set({ id: 1 } as any)
fixture = TestBed.createComponent(TagsComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
@@ -136,20 +136,13 @@ describe('TagsComponent', () => {
})
it('should support create new using last search term and open a modal', () => {
settingsService.currentUser = { id: 1 }
settingsService.currentUser.set({ id: 1 })
let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.select.filter('foobar')
component.createTag()
expect(modalService.hasOpenModals()).toBeTruthy()
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
const editDialog = activeInstances[0]
.componentInstance as TagEditDialogComponent
editDialog.save() // create is mocked
fixture.detectChanges()
fixture.whenStable().then(() => {
expect(fixture.debugElement.nativeElement.textContent).toContain('foobar')
})
})
it('support remove tags', () => {
@@ -1,4 +1,5 @@
import {
ChangeDetectorRef,
Component,
EventEmitter,
forwardRef,
@@ -49,6 +50,7 @@ import { TagComponent } from '../../tag/tag.component'
export class TagsComponent implements OnInit, ControlValueAccessor {
private tagService = inject(TagService)
private modalService = inject(NgbModal)
private readonly changeDetector = inject(ChangeDetectorRef)
constructor() {
this.createTagRef = this.createTag.bind(this)
@@ -60,6 +62,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
writeValue(newValue: number[]): void {
this.value = newValue
this.changeDetector.markForCheck()
}
registerOnChange(fn: any): void {
this.onChange = fn
@@ -69,11 +72,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled
this.changeDetector.markForCheck()
}
ngOnInit(): void {
this.tagService.listAll().subscribe((result) => {
this.tags = result.results
this.changeDetector.markForCheck()
})
}
@@ -176,7 +181,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
var modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
if (name) modal.componentInstance.object = { name: name }
else if (this.select.searchTerm)
modal.componentInstance.object = { name: this.select.searchTerm }
@@ -1,7 +1,7 @@
@if (customLogo) {
<img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height" />
<img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height()" />
} @else {
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2670 860" [attr.style]="'height:'+height">
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2670 860" [attr.style]="'height:'+height()">
<path class="leaf" style="fill:#005616;" d="M2227.4,821.2c-6.1-17.8-18.1-53.6-19.2-53.4-174.7-77.8-159.8-201.2-117.5-304.2,26.3,120.1,235.3,130.3,128,294.1-.7,2,8.8,24.3,17.1,44.9,19.9-45.4,51.3-101.1,48.8-105.7-199.9-357.4,278.8-444.7,350.7-690.2,72.6,220.1,46.5,577.5-330.4,713.3-1.8,1.2-55.6,130-58.5,131.4-.2-1.9-29.1,2.5-26.4-7.6,1.4-6.2,4.2-14.2,7.2-22.4h0v-.2h.2,0ZM2211.7,731.2c42.3-62.9-11.1-105.7-49.8-133.2,71,94,58.1,105.7,49.8,133.2h0Z"/>
<g class="text" style="fill: #000;">
<path class="st1" d="M654.6,393.2l-.7,137.7h-85.5V188.7h85.4c.4,11.3-.3,21.7,1.3,33.8,23.1-34.1,62.3-50,101.1-38.3,16.5,5,29.6,16.4,39.7,30,34.4,46.5,35.1,134,3.6,182.2-10.1,14.4-22.5,26.9-39,33.4-39.5,15.7-81,1.1-105.9-36.6h0ZM721,362.2c21-26.1,21-82.7-.4-108.4-13.2-15.9-36.4-16.1-49.9-.4-22.2,25.8-21.7,85.3.5,110.1,13.6,15.2,36.6,15,49.7-1.3h.1Z"/>
@@ -28,7 +28,7 @@ describe('LogoComponent', () => {
it('should support extra classes', () => {
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(0)
component.extra_classes = 'foo'
fixture.componentRef.setInput('extra_classes', 'foo')
fixture.detectChanges()
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(1)
})
@@ -37,7 +37,7 @@ describe('LogoComponent', () => {
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
'height:6em'
)
component.height = '10em'
fixture.componentRef.setInput('height', '10em')
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
'height:10em'
@@ -1,4 +1,4 @@
import { Component, Input, inject } from '@angular/core'
import { Component, inject, input } from '@angular/core'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
import { environment } from 'src/environments/environment'
@@ -10,12 +10,8 @@ import { environment } from 'src/environments/environment'
})
export class LogoComponent {
private settingsService = inject(SettingsService)
@Input()
extra_classes: string
@Input()
height = '6em'
readonly extra_classes = input<string>(undefined)
readonly height = input('6em')
get customLogo(): string {
return this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
@@ -27,6 +23,6 @@ export class LogoComponent {
}
getClasses() {
return ['logo'].concat(this.extra_classes).join(' ')
return ['logo'].concat(this.extra_classes()).join(' ')
}
}
@@ -1,32 +1,32 @@
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
<div class="col-md text-truncate">
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
<span class="text-truncate">{{title}}</span>
@if (id) {
<span class="text-truncate">{{title()}}</span>
@if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied) {
@if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{id}}
ID: {{id()}}
}
</span>
}
@if (subTitle) {
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
@if (subTitle()) {
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
}
@if (info) {
@if (info()) {
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs>
</button>
<ng-template #infoPopover>
<p [class.mb-0]="!infoLink" [innerHTML]="info"></p>
@if (infoLink) {
<a href="https://docs.paperless-ngx.com/{{infoLink}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
<p [class.mb-0]="!infoLink()" [innerHTML]="info()"></p>
@if (infoLink()) {
<a href="https://docs.paperless-ngx.com/{{infoLink()}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
}
</ng-template>
}
@if (loading) {
@if (loading()) {
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
}
</h3>
@@ -1,6 +1,7 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Title } from '@angular/platform-browser'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { environment } from 'src/environments/environment'
import { PageHeaderComponent } from './page-header.component'
@@ -13,7 +14,7 @@ describe('PageHeaderComponent', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [],
imports: [PageHeaderComponent],
imports: [NgxBootstrapIconsModule.pick(allIcons), PageHeaderComponent],
}).compileComponents()
titleService = TestBed.inject(Title)
@@ -23,9 +24,13 @@ describe('PageHeaderComponent', () => {
fixture.detectChanges()
})
afterEach(() => {
jest.useRealTimers()
})
it('should display title + subtitle', () => {
component.title = 'Foo'
component.subTitle = 'Bar'
fixture.componentRef.setInput('title', 'Foo')
fixture.componentRef.setInput('subTitle', 'Bar')
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Foo')
expect(fixture.nativeElement.textContent).toContain('Bar')
@@ -33,19 +38,20 @@ describe('PageHeaderComponent', () => {
it('should set html title', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
component.title = 'Foo Bar'
fixture.componentRef.setInput('title', 'Foo Bar')
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
})
it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers()
component.id = 42 as any
fixture.componentRef.setInput('id', 42)
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
component.copyID()
expect(clipboard.copy).toHaveBeenCalledWith('42')
expect(component.copied).toBe(true)
expect(component.copied()).toBe(true)
jest.advanceTimersByTime(3000)
expect(component.copied).toBe(false)
expect(component.copied()).toBe(false)
})
})
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, inject } from '@angular/core'
import { Component, effect, inject, input, signal } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -16,40 +16,26 @@ export class PageHeaderComponent {
private titleService = inject(Title)
private clipboard = inject(Clipboard)
private _title = ''
public copied: boolean = false
readonly id = input<number>(undefined)
readonly subTitle = input('')
readonly info = input<string>(undefined)
readonly infoLink = input<string>(undefined)
readonly loading = input(false)
readonly title = input('')
readonly copied = signal(false)
private copyTimeout: any
@Input()
set title(title: string) {
this._title = title
this.titleService.setTitle(`${this.title} - ${environment.appTitle}`)
constructor() {
effect(() => {
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
})
}
get title() {
return this._title
}
@Input()
id: number
@Input()
subTitle: string = ''
@Input()
info: string
@Input()
infoLink: string
@Input()
loading: boolean = false
public copyID() {
this.copied = this.clipboard.copy(this.id.toString())
this.copied.set(this.clipboard.copy(this.id().toString()))
clearTimeout(this.copyTimeout)
this.copyTimeout = setTimeout(() => {
this.copied = false
this.copied.set(false)
}, 3000)
}
}
@@ -146,8 +146,8 @@ describe('PDFEditorComponent', () => {
const previewSpy = jest
.spyOn(documentService, 'getPreviewUrl')
.mockReturnValue('preview-version')
component.documentID = 3
component.versionID = 10
component.documentID.set(3)
component.versionID.set(10)
expect(component.pdfSrc).toBe('preview-version')
expect(previewSpy).toHaveBeenCalledWith(3, false, 10)
@@ -3,7 +3,7 @@ import {
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -45,8 +45,9 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
private readonly settingsService = inject(SettingsService)
activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number
versionID?: number
readonly documentID = signal<number>(undefined)
readonly versionID = signal<number>(undefined)
pages: PageOperation[] = []
totalPages = 0
editMode: PdfEditorEditMode = this.settingsService.get(
@@ -57,9 +58,9 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
get pdfSrc(): string {
return this.documentService.getPreviewUrl(
this.documentID,
this.documentID(),
false,
this.versionID
this.versionID()
)
}
@@ -291,7 +291,7 @@ export class PngxPdfViewerComponent
this.eventBus.dispatch('find', {
query,
caseSensitive: false,
highlightAll: query.length > 0,
highlightAll: query?.length > 0,
phraseSearch: true,
})
}
@@ -1,5 +1,5 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
</button>
</div>
@@ -7,7 +7,7 @@
<form [formGroup]="form">
<div class="form-group">
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
</div>
<div class="form-group mt-4">
<div class="offset-lg-3 row">
@@ -16,18 +16,18 @@
</div>
</form>
@if (note) {
@if (note()) {
<div class="small text-muted fst-italic mt-2">
{{ note }}
{{ note() }}
</div>
}
</div>
<div class="modal-footer">
@if (!buttonsEnabled) {
@if (!buttonsEnabled()) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span class="visually-hidden" i18n>Loading...</span>
}
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled" i18n>Confirm</button>
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled()" i18n>Confirm</button>
</div>
@@ -109,7 +109,7 @@ describe('PermissionsDialogComponent', () => {
permissions: set_permissions.set_permissions,
}
component.object = obj
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
expect(component.title()).toEqual(`Edit permissions for ${obj.name}`)
expect(component.permissions).toEqual(set_permissions)
})
@@ -1,4 +1,12 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
FormControl,
FormGroup,
@@ -6,6 +14,7 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { map } from 'rxjs'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service'
@@ -27,26 +36,22 @@ export class PermissionsDialogComponent {
activeModal = inject(NgbActiveModal)
private userService = inject(UserService)
users: User[]
readonly users = toSignal(
this.userService.listAll().pipe(map((r) => r.results)),
{ initialValue: undefined as User[] }
)
readonly title = signal($localize`Set permissions`)
readonly note = signal<string>(null)
readonly buttonsEnabled = signal(true)
private o: ObjectWithPermissions = undefined
constructor() {
this.userService.listAll().subscribe((r) => (this.users = r.results))
}
@Output()
public confirmClicked = new EventEmitter()
@Input()
title = $localize`Set permissions`
@Input()
note: string = null
@Input()
set object(o: ObjectWithPermissions) {
this.o = o
this.title = $localize`Edit permissions for ` + o['name']
this.title.set($localize`Edit permissions for ` + o['name'])
this.form.patchValue({
merge: true,
permissions_form: {
@@ -65,8 +70,6 @@ export class PermissionsDialogComponent {
merge: new FormControl(true),
})
buttonsEnabled: boolean = true
get permissions() {
return {
owner: this.form.get('permissions_form').value?.owner ?? null,
@@ -64,9 +64,9 @@ describe('PermissionsFilterDropdownComponent', () => {
{
provide: SettingsService,
useValue: {
currentUser: {
currentUser: () => ({
id: currentUserID,
},
}),
},
},
provideHttpClient(withInterceptorsFromDi()),
@@ -117,12 +117,12 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
this.selectionModel.userID = this.settingsService.currentUser.id
this.selectionModel.userID = this.settingsService.currentUser().id
this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
this.selectionModel.userID = null
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = [this.settingsService.currentUser.id]
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
this.selectionModel.userID = null
@@ -132,7 +132,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} else if (
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
) {
this.selectionModel.userID = this.settingsService.currentUser.id
this.selectionModel.userID = this.settingsService.currentUser()?.id
this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = []
this.selectionModel.hideUnowned = false
@@ -1,28 +1,28 @@
<a [href]="link ?? previewUrl" class="{{linkClasses}}" [target]="linkTarget" [title]="linkTitle"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" container="body"
autoClose="true" [popoverClass]="popoverClass" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
autoClose="true" [popoverClass]="popoverClass()" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<ng-content></ng-content>
</a>
<ng-template #previewContent>
<div class="preview-popup-container" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview(); close()">
@if (error) {
@if (error()) {
<div class="w-100 h-100 position-relative">
<p class="fst-italic position-absolute top-50 start-50 translate-middle" i18n>Error loading preview</p>
</div>
} @else {
@if (renderAsObject) {
@if (previewText) {
<div class="bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{previewText}}</div>
@if (previewText()) {
<div class="bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{previewText()}}</div>
} @else {
<object [data]="previewUrl | safeUrl" width="100%" class="bg-light" [class.p-2]="!isPdf"></object>
}
} @else {
@if (requiresPassword) {
@if (requiresPassword()) {
<div class="w-100 h-100 position-relative">
<i-bs width="2em" height="2em" class="position-absolute top-50 start-50 translate-middle" name="file-earmark-lock"></i-bs>
</div>
}
@if (!requiresPassword) {
@if (!requiresPassword()) {
<pngx-pdf-viewer
[src]="previewUrl"
[renderMode]="PdfRenderMode.All"
@@ -87,7 +87,7 @@ describe('PreviewPopupComponent', () => {
component.popover.open()
component.onError({ name: 'PasswordException' })
fixture.detectChanges()
expect(component.requiresPassword).toBeTruthy()
expect(component.requiresPassword()).toBeTruthy()
expect(fixture.debugElement.query(By.css('i-bs'))).not.toBeNull()
})
@@ -121,16 +121,18 @@ describe('PreviewPopupComponent', () => {
)
component.init()
expect(httpSpy).toHaveBeenCalled()
expect(component.error).toBeTruthy()
expect(component.error()).toBeTruthy()
httpSpy.mockReturnValueOnce(of('Preview text'))
component.init()
expect(component.previewText).toEqual('Preview text')
expect(component.previewText()).toEqual('Preview text')
})
it('should show preview on mouseover after delay to preload content', () => {
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
expect(component.popoverClass()).toContain('opacity-0')
jest.advanceTimersByTime(600)
expect(component.popoverClass()).not.toContain('opacity-0')
component.close()
jest.advanceTimersByTime(600)
})
@@ -1,5 +1,12 @@
import { HttpClient } from '@angular/common/http'
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
import {
Component,
inject,
Input,
OnDestroy,
signal,
ViewChild,
} from '@angular/core'
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, Subject, takeUntil } from 'rxjs'
@@ -55,17 +62,17 @@ export class PreviewPopupComponent implements OnDestroy {
unsubscribeNotifier: Subject<any> = new Subject()
error = false
readonly error = signal(false)
requiresPassword: boolean = false
readonly requiresPassword = signal(false)
previewText: string
readonly previewText = signal<string>(null)
@ViewChild('popover') popover: NgbPopover
mouseOnPreview: boolean = false
readonly mouseOnPreview = signal(false)
popoverClass: string = 'shadow popover-preview'
readonly popoverClass = signal('shadow popover-preview')
get renderAsObject(): boolean {
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
@@ -97,10 +104,10 @@ export class PreviewPopupComponent implements OnDestroy {
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (res) => {
this.previewText = res.toString()
this.previewText.set(res.toString())
},
error: (err) => {
this.error = err
this.error.set(err)
},
})
}
@@ -108,22 +115,24 @@ export class PreviewPopupComponent implements OnDestroy {
onError(event: any) {
if (event.name == 'PasswordException') {
this.requiresPassword = true
this.requiresPassword.set(true)
} else {
this.error = true
this.error.set(true)
}
}
mouseEnterPreview() {
this.mouseOnPreview = true
this.mouseOnPreview.set(true)
if (!this.popover.isOpen()) {
// we're going to open but hide to pre-load content during hover delay
this.popover.open()
this.popoverClass = 'shadow popover-preview pe-none opacity-0'
this.popoverClass.set('shadow popover-preview pe-none opacity-0')
setTimeout(() => {
if (this.mouseOnPreview) {
if (this.mouseOnPreview()) {
// show popover
this.popoverClass = this.popoverClass.replace('pe-none opacity-0', '')
this.popoverClass.set(
this.popoverClass().replace('pe-none opacity-0', '')
)
} else {
this.popover.close(true)
}
@@ -132,13 +141,13 @@ export class PreviewPopupComponent implements OnDestroy {
}
mouseLeavePreview() {
this.mouseOnPreview = false
this.mouseOnPreview.set(false)
}
public close(immediate: boolean = false) {
setTimeout(
() => {
if (!this.mouseOnPreview) this.popover.close()
if (!this.mouseOnPreview()) this.popover.close()
},
immediate ? 0 : 300
)
@@ -7,38 +7,38 @@
<div class="modal-body">
<div class="row">
<div class="col-12 col-md-6">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error()?.email"></pngx-input-text>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm()" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error()?.email_confirm"></pngx-input-text>
</div>
</div>
</div>
</div>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error()?.password"></pngx-input-password>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm()" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error()?.password_confirm"></pngx-input-password>
</div>
</div>
</div>
</div>
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error()?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error()?.first_name"></pngx-input-text>
<div class="mb-3">
<label class="form-label" i18n>API Auth Token</label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" formControlName="auth_token" readonly>
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
@if (!copied) {
@if (!copied()) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
}
@if (copied) {
@if (copied()) {
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
@@ -51,7 +51,7 @@
(confirm)="generateAuthToken()">
</pngx-confirm-button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied()" i18n>Copied!</span>
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
@@ -155,10 +155,10 @@
}
</ul>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
@if (!codesCopied) {
@if (!codesCopied()) {
<i-bs width="1em" height="1em" name="clipboard-fill" class="me-1"></i-bs><span i18n>Copy codes</span>
}
@if (codesCopied) {
@if (codesCopied()) {
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary me-1"></i-bs><span class="text-primary" i18n>Copied!</span>
}
</button>
@@ -179,7 +179,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || saveDisabled">Save</button>
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive()">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive() || saveDisabled">Save</button>
</div>
</form>
@@ -1,9 +1,4 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
@@ -191,7 +186,8 @@ describe('ProfileEditDialogComponent', () => {
expect(component.saveDisabled).toBeFalsy()
})
it('should logout on save if password changed', fakeAsync(() => {
it('should logout on save if password changed', () => {
jest.useFakeTimers()
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
const getProvidersSpy = jest.spyOn(
@@ -211,13 +207,15 @@ describe('ProfileEditDialogComponent', () => {
.mockImplementation(() => {})
component.save()
expect(updateSpy).toHaveBeenCalled()
tick(2600)
jest.advanceTimersByTime(2600)
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}))
jest.useRealTimers()
})
it('should support auth token copy', fakeAsync(() => {
it('should support auth token copy', () => {
jest.useFakeTimers()
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
const getProvidersSpy = jest.spyOn(
@@ -229,10 +227,11 @@ describe('ProfileEditDialogComponent', () => {
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyAuthToken()
expect(copySpy).toHaveBeenCalledWith(profile.auth_token)
expect(component.copied).toBeTruthy()
tick(3000)
expect(component.copied).toBeFalsy()
}))
expect(component.copied()).toBeTruthy()
jest.advanceTimersByTime(3000)
expect(component.copied()).toBeFalsy()
jest.useRealTimers()
})
it('should support generate token, display error if needed', () => {
const getSpy = jest.spyOn(profileService, 'get')
@@ -366,11 +365,13 @@ describe('ProfileEditDialogComponent', () => {
expect(component.isTotpEnabled).toBeFalsy()
})
it('should copy recovery codes', fakeAsync(() => {
it('should copy recovery codes', () => {
jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
tick(3000)
}))
jest.advanceTimersByTime(3000)
jest.useRealTimers()
})
})
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -50,8 +50,12 @@ export class ProfileEditDialogComponent
private toastService = inject(ToastService)
private clipboard = inject(Clipboard)
public networkActive: boolean = false
public error: any
readonly networkActive = signal(false)
readonly error = signal<any>(undefined)
readonly showPasswordConfirm = signal(false)
readonly showEmailConfirm = signal(false)
readonly copied = signal(false)
readonly codesCopied = signal(false)
public form = new FormGroup({
email: new FormControl(''),
@@ -67,23 +71,18 @@ export class ProfileEditDialogComponent
private currentPassword: string
private newPassword: string
private passwordConfirm: string
public showPasswordConfirm: boolean = false
public hasUsablePassword: boolean = false
private currentEmail: string
private newEmail: string
private emailConfirm: string
public showEmailConfirm: boolean = false
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public copied: boolean = false
public codesCopied: boolean = false
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
@@ -95,12 +94,12 @@ export class ProfileEditDialogComponent
}
ngOnInit(): void {
this.networkActive = true
this.networkActive.set(true)
this.profileService
.get()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((profile) => {
this.networkActive = false
this.networkActive.set(false)
this.form.patchValue(profile)
this.currentEmail = profile.email
this.form.get('email').valueChanges.subscribe((newEmail) => {
@@ -126,7 +125,7 @@ export class ProfileEditDialogComponent
}
get saveDisabled(): boolean {
return this.error?.password_confirm || this.error?.email_confirm
return this.error()?.password_confirm || this.error()?.email_confirm
}
onEmailKeyUp(event: KeyboardEvent): void {
@@ -140,18 +139,17 @@ export class ProfileEditDialogComponent
}
onEmailChange(): void {
this.showEmailConfirm = this.currentEmail !== this.newEmail
if (this.showEmailConfirm) {
this.showEmailConfirm.set(this.currentEmail !== this.newEmail)
if (this.showEmailConfirm()) {
this.form.get('email_confirm').enable()
if (this.newEmail !== this.emailConfirm) {
if (!this.error) this.error = {}
this.error.email_confirm = $localize`Emails must match`
this.setFieldError('email_confirm', $localize`Emails must match`)
} else {
delete this.error?.email_confirm
this.clearFieldError('email_confirm')
}
} else {
this.form.get('email_confirm').disable()
delete this.error?.email_confirm
this.clearFieldError('email_confirm')
}
}
@@ -167,29 +165,42 @@ export class ProfileEditDialogComponent
}
onPasswordChange(): void {
this.showPasswordConfirm = this.currentPassword !== this.newPassword
this.showPasswordConfirm.set(this.currentPassword !== this.newPassword)
if (this.showPasswordConfirm) {
if (this.showPasswordConfirm()) {
this.form.get('password_confirm').enable()
if (this.newPassword !== this.passwordConfirm) {
if (!this.error) this.error = {}
this.error.password_confirm = $localize`Passwords must match`
this.setFieldError('password_confirm', $localize`Passwords must match`)
} else {
delete this.error?.password_confirm
this.clearFieldError('password_confirm')
}
} else {
this.form.get('password_confirm').disable()
delete this.error?.password_confirm
this.clearFieldError('password_confirm')
}
}
private setFieldError(fieldName: string, message: string): void {
this.error.set({
...this.error(),
[fieldName]: message,
})
}
private clearFieldError(fieldName: string): void {
if (!this.error()) return
const error = { ...this.error() }
delete error[fieldName]
this.error.set(Object.keys(error).length ? error : undefined)
}
save(): void {
const passwordChanged =
this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value)
delete profile.totp_code
this.error = null
this.networkActive = true
this.error.set(null)
this.networkActive.set(true)
this.profileService
.update(profile)
.pipe(takeUntil(this.unsubscribeNotifier))
@@ -210,8 +221,8 @@ export class ProfileEditDialogComponent
},
error: (error) => {
this.toastService.showError($localize`Error saving profile`, error)
this.error = error?.error
this.networkActive = false
this.error.set(error?.error)
this.networkActive.set(false)
},
})
}
@@ -236,9 +247,9 @@ export class ProfileEditDialogComponent
copyAuthToken(): void {
this.clipboard.copy(this.form.get('auth_token').value)
this.copied = true
this.copied.set(true)
setTimeout(() => {
this.copied = false
this.copied.set(false)
}, 3000)
}
@@ -330,9 +341,9 @@ export class ProfileEditDialogComponent
public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied = true
this.codesCopied.set(true)
setTimeout(() => {
this.codesCopied = false
this.codesCopied.set(false)
}, 3000)
}
}
@@ -8,18 +8,18 @@
<div>
<p class="mb-1">
<ng-container i18n>Selected documents:</ng-container>
{{ selectionCount }}
{{ selectionCount() }}
</p>
@if (documentPreview.length > 0) {
@if (documentPreview().length > 0) {
<ul class="list-unstyled small mb-0">
@for (doc of documentPreview; track doc.id) {
@for (doc of documentPreview(); track doc.id) {
<li>
<strong>{{ doc.title | documentTitle }}</strong>
</li>
}
@if (selectionCount > documentPreview.length) {
@if (selectionCount() > documentPreview().length) {
<li>
<ng-container i18n>+ {{ selectionCount - documentPreview.length }} more…</ng-container>
<ng-container i18n>+ {{ selectionCount() - documentPreview().length }} more…</ng-container>
</li>
}
</ul>
@@ -72,10 +72,10 @@
type="button"
(click)="copy(createdBundle)"
>
@if (copied) {
@if (copied()) {
<i-bs name="clipboard-check"></i-bs>
}
@if (!copied) {
@if (!copied()) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy link</span>
@@ -118,8 +118,8 @@
type="button"
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
(click)="submit()"
[disabled]="loading || !buttonsEnabled">
@if (loading) {
[disabled]="loading() || !buttonsEnabled">
@if (loading()) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
{{ btnCaption }}
@@ -1,10 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { FileVersion } from 'src/app/data/share-link'
@@ -58,10 +53,10 @@ describe('ShareLinkBundleDialogComponent', () => {
it('builds payload and emits confirm on submit', () => {
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
component.documents = [
component.setDocuments([
{ id: 1, title: 'Doc 1' } as any,
{ id: 2, title: 'Doc 2' } as any,
]
])
component.form.setValue({
shareArchiveVersion: false,
expirationDays: 3,
@@ -101,14 +96,15 @@ describe('ShareLinkBundleDialogComponent', () => {
const docs = Array.from({ length: 12 }).map((_, index) => ({
id: index + 1,
}))
component.documents = docs as any
component.setDocuments(docs as any)
expect(component.selectionCount).toBe(12)
expect(component.documentPreview).toHaveLength(10)
expect(component.documentPreview[0].id).toBe(1)
expect(component.selectionCount()).toBe(12)
expect(component.documentPreview()).toHaveLength(10)
expect(component.documentPreview()[0].id).toBe(1)
})
it('copies share link and resets state after timeout', fakeAsync(() => {
it('copies share link and resets state after timeout', () => {
jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
const bundle = {
slug: 'bundle-slug',
@@ -118,12 +114,13 @@ describe('ShareLinkBundleDialogComponent', () => {
component.copy(bundle)
expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
expect(component.copied).toBe(true)
expect(component.copied()).toBe(true)
expect(toastService.showInfo).toHaveBeenCalled()
tick(3000)
expect(component.copied).toBe(false)
}))
jest.advanceTimersByTime(3000)
expect(component.copied()).toBe(false)
jest.useRealTimers()
})
it('generates share URLs based on API base URL', () => {
environment.apiBaseUrl = 'https://example.com/api/'
@@ -1,6 +1,6 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common'
import { Component, Input, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Document } from 'src/app/data/document'
@@ -38,10 +38,11 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
private readonly clipboard = inject(Clipboard)
private readonly toastService = inject(ToastService)
private _documents: Document[] = []
readonly documents = signal<Document[]>([])
readonly selectionCount = signal(0)
readonly documentPreview = signal<Document[]>([])
readonly copied = signal(false)
selectionCount = 0
documentPreview: Document[] = []
form: FormGroup = this.formBuilder.group({
shareArchiveVersion: true,
expirationDays: [7],
@@ -51,28 +52,27 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
createdBundle: ShareLinkBundleSummary | null = null
copied = false
onOpenManage?: () => void
readonly statuses = ShareLinkBundleStatus
constructor() {
super()
this.loading = false
this.loading.set(false)
this.title = $localize`Create share link bundle`
this.btnCaption = $localize`Create link`
}
@Input()
set documents(docs: Document[]) {
this._documents = docs.concat()
this.selectionCount = this._documents.length
this.documentPreview = this._documents.slice(0, 10)
setDocuments(docs: Document[]) {
const documents = docs.concat()
this.documents.set(documents)
this.selectionCount.set(documents.length)
this.documentPreview.set(documents.slice(0, 10))
}
submit() {
if (this.createdBundle) return
this.payload = {
document_ids: this._documents.map((doc) => doc.id),
document_ids: this.documents().map((doc) => doc.id),
file_version: this.form.value.shareArchiveVersion
? FileVersion.Archive
: FileVersion.Original,
@@ -92,10 +92,10 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
copy(bundle: ShareLinkBundleSummary): void {
const success = this.clipboard.copy(this.getShareUrl(bundle))
if (success) {
this.copied = true
this.copied.set(true)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
setTimeout(() => {
this.copied = false
this.copied.set(false)
}, 3000)
}
}
@@ -4,27 +4,27 @@
</div>
<div class="modal-body">
@if (loading) {
@if (loading()) {
<div class="d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span i18n>Loading share link bundles…</span>
</div>
}
@if (!loading && error) {
@if (!loading() && error()) {
<div class="alert alert-danger mb-0" role="alert">
{{ error }}
{{ error() }}
</div>
}
@if (!loading && !error) {
@if (!loading() && !error()) {
<div class="d-flex justify-content-between align-items-center mb-2">
<p class="mb-0 text-muted small">
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
</p>
</div>
@if (bundles.length === 0) {
@if (bundles().length === 0) {
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
}
@if (bundles.length > 0) {
@if (bundles().length > 0) {
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
@@ -39,7 +39,7 @@
</tr>
</thead>
<tbody>
@for (bundle of bundles; track bundle.id) {
@for (bundle of bundles(); track bundle.id) {
<tr>
<td>
<div>{{ bundle.created | date: 'short' }}</div>
@@ -113,10 +113,10 @@
title="Copy share link"
i18n-title
>
@if (copiedSlug === bundle.slug) {
@if (copiedSlug() === bundle.slug) {
<i-bs name="clipboard-check"></i-bs>
}
@if (copiedSlug !== bundle.slug) {
@if (copiedSlug() !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
@@ -125,7 +125,7 @@
<button
type="button"
class="btn btn-outline-warning"
[disabled]="loading"
[disabled]="loading()"
(click)="retry(bundle)"
>
<i-bs name="arrow-clockwise"></i-bs>
@@ -134,7 +134,7 @@
}
<pngx-confirm-button
buttonClasses="btn btn-sm btn-outline-danger"
[disabled]="loading"
[disabled]="loading()"
(confirm)="delete(bundle)"
iconName="trash"
>
@@ -1,10 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
@@ -70,6 +65,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
fixture.destroy()
environment.apiBaseUrl = originalApiBaseUrl
jest.clearAllMocks()
jest.useRealTimers()
})
const sampleBundle = (overrides: Partial<ShareLinkBundleSummary> = {}) =>
@@ -85,7 +81,8 @@ describe('ShareLinkBundleManageDialogComponent', () => {
...overrides,
}) as ShareLinkBundleSummary
it('loads bundles on init and polls periodically', fakeAsync(() => {
it('loads bundles on init and polls periodically', () => {
jest.useFakeTimers()
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
service.listAllBundles.mockReset()
service.listAllBundles
@@ -93,38 +90,37 @@ describe('ShareLinkBundleManageDialogComponent', () => {
.mockReturnValue(of(bundles))
fixture.detectChanges()
tick()
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
expect(component.bundles).toEqual(bundles)
expect(component.loading).toBe(false)
expect(component.error).toBeNull()
expect(component.bundles()).toEqual(bundles)
expect(component.loading()).toBe(false)
expect(component.error()).toBeNull()
tick(5000)
jest.advanceTimersByTime(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
}))
})
it('handles errors when loading bundles', fakeAsync(() => {
it('handles errors when loading bundles', () => {
jest.useFakeTimers()
service.listAllBundles.mockReset()
service.listAllBundles
.mockReturnValueOnce(throwError(() => new Error('load fail')))
.mockReturnValue(of([]))
fixture.detectChanges()
tick()
expect(component.error).toContain('Failed to load share link bundles.')
expect(component.error()).toContain('Failed to load share link bundles.')
expect(toastService.showError).toHaveBeenCalled()
expect(component.loading).toBe(false)
expect(component.loading()).toBe(false)
tick(5000)
jest.advanceTimersByTime(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
}))
})
it('copies bundle links when ready', fakeAsync(() => {
it('copies bundle links when ready', () => {
jest.useFakeTimers()
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
fixture.detectChanges()
tick()
const readyBundle = sampleBundle({
slug: 'ready-slug',
@@ -135,103 +131,91 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(clipboard.copy).toHaveBeenCalledWith(
component.getShareUrl(readyBundle)
)
expect(component.copiedSlug).toBe('ready-slug')
expect(component.copiedSlug()).toBe('ready-slug')
expect(toastService.showInfo).toHaveBeenCalled()
tick(3000)
expect(component.copiedSlug).toBeNull()
}))
jest.advanceTimersByTime(3000)
expect(component.copiedSlug()).toBeNull()
})
it('ignores copy requests for non-ready bundles', fakeAsync(() => {
it('ignores copy requests for non-ready bundles', () => {
const copySpy = jest.spyOn(clipboard, 'copy')
fixture.detectChanges()
tick()
component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending }))
expect(copySpy).not.toHaveBeenCalled()
}))
})
it('deletes bundles and refreshes list', fakeAsync(() => {
it('deletes bundles and refreshes list', () => {
service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(of(true))
fixture.detectChanges()
tick()
component.delete(sampleBundle())
tick()
expect(service.delete).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalledWith(
expect.stringContaining('deleted.')
)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(component.loading).toBe(false)
}))
expect(component.loading()).toBe(false)
})
it('handles delete errors gracefully', fakeAsync(() => {
it('handles delete errors gracefully', () => {
service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
fixture.detectChanges()
tick()
component.delete(sampleBundle())
tick()
expect(toastService.showError).toHaveBeenCalled()
expect(component.loading).toBe(false)
}))
expect(component.loading()).toBe(false)
})
it('retries bundle build and replaces existing entry', fakeAsync(() => {
it('retries bundle build and replaces existing entry', () => {
service.listAllBundles.mockReturnValue(of([]))
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
service.rebuildBundle.mockReturnValue(of(updated))
fixture.detectChanges()
tick()
component.bundles = [sampleBundle()]
component.retry(component.bundles[0])
tick()
component.bundles.set([sampleBundle()])
component.retry(component.bundles()[0])
expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id)
expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready)
expect(component.bundles()[0].status).toBe(ShareLinkBundleStatus.Ready)
expect(toastService.showInfo).toHaveBeenCalled()
}))
})
it('adds new bundle when retry returns unknown entry', fakeAsync(() => {
it('adds new bundle when retry returns unknown entry', () => {
service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue(
of(sampleBundle({ id: 99, slug: 'new-slug' }))
)
fixture.detectChanges()
tick()
component.bundles = [sampleBundle()]
component.bundles.set([sampleBundle()])
component.retry({ id: 99 } as ShareLinkBundleSummary)
tick()
expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy()
}))
expect(component.bundles().find((bundle) => bundle.id === 99)).toBeTruthy()
})
it('handles retry errors', fakeAsync(() => {
it('handles retry errors', () => {
service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
fixture.detectChanges()
tick()
component.retry(sampleBundle())
tick()
expect(toastService.showError).toHaveBeenCalled()
}))
})
it('maps helpers and closes dialog', fakeAsync(() => {
it('maps helpers and closes dialog', () => {
service.listAllBundles.mockReturnValue(of([]))
fixture.detectChanges()
tick()
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
'Processing'
@@ -247,5 +231,5 @@ describe('ShareLinkBundleManageDialogComponent', () => {
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
}))
})
})
@@ -1,6 +1,6 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
@@ -40,10 +40,9 @@ export class ShareLinkBundleManageDialogComponent
private readonly clipboard = inject(Clipboard)
title = $localize`Share link bundles`
bundles: ShareLinkBundleSummary[] = []
error: string | null = null
copiedSlug: string | null = null
readonly bundles = signal<ShareLinkBundleSummary[]>([])
readonly error = signal<string | null>(null)
readonly copiedSlug = signal<string | null>(null)
readonly statuses = ShareLinkBundleStatus
readonly fileVersions = FileVersion
@@ -55,15 +54,15 @@ export class ShareLinkBundleManageDialogComponent
.pipe(
switchMap((silent) => {
if (!silent) {
this.loading = true
this.loading.set(true)
}
this.error = null
this.error.set(null)
return this.shareLinkBundleService.listAllBundles().pipe(
catchError((error) => {
if (!silent) {
this.loading = false
this.loading.set(false)
}
this.error = $localize`Failed to load share link bundles.`
this.error.set($localize`Failed to load share link bundles.`)
this.toastService.showError(
$localize`Error retrieving share link bundles.`,
error
@@ -76,10 +75,10 @@ export class ShareLinkBundleManageDialogComponent
)
.subscribe((results) => {
if (results) {
this.bundles = results
this.copiedSlug = null
this.bundles.set(results)
this.copiedSlug.set(null)
}
this.loading = false
this.loading.set(false)
})
this.triggerRefresh(false)
@@ -105,24 +104,24 @@ export class ShareLinkBundleManageDialogComponent
}
const success = this.clipboard.copy(this.getShareUrl(bundle))
if (success) {
this.copiedSlug = bundle.slug
this.copiedSlug.set(bundle.slug)
setTimeout(() => {
this.copiedSlug = null
this.copiedSlug.set(null)
}, 3000)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
}
}
delete(bundle: ShareLinkBundleSummary): void {
this.error = null
this.loading = true
this.error.set(null)
this.loading.set(true)
this.shareLinkBundleService.delete(bundle).subscribe({
next: () => {
this.toastService.showInfo($localize`Share link bundle deleted.`)
this.triggerRefresh(false)
},
error: (e) => {
this.loading = false
this.loading.set(false)
this.toastService.showError(
$localize`Error deleting share link bundle.`,
e
@@ -132,7 +131,7 @@ export class ShareLinkBundleManageDialogComponent
}
retry(bundle: ShareLinkBundleSummary): void {
this.error = null
this.error.set(null)
this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({
next: (updated) => {
this.toastService.showInfo(
@@ -159,15 +158,16 @@ export class ShareLinkBundleManageDialogComponent
}
private replaceBundle(updated: ShareLinkBundleSummary): void {
const index = this.bundles.findIndex((bundle) => bundle.id === updated.id)
const bundles = this.bundles()
const index = bundles.findIndex((bundle) => bundle.id === updated.id)
if (index >= 0) {
this.bundles = [
...this.bundles.slice(0, index),
this.bundles.set([
...bundles.slice(0, index),
updated,
...this.bundles.slice(index + 1),
]
...bundles.slice(index + 1),
])
} else {
this.bundles = [updated, ...this.bundles]
this.bundles.set([updated, ...bundles])
}
}
@@ -1,15 +1,15 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body p-0">
<ul class="list-group list-group-flush">
@if (!shareLinks || shareLinks.length === 0) {
@if (!shareLinks() || shareLinks().length === 0) {
<li class="list-group-item fst-italic small text-center text-secondary" i18n>
No existing links
</li>
}
@for (link of shareLinks; track link) {
@for (link of shareLinks(); track link) {
<li class="list-group-item">
<div class="input-group w-100">
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
@@ -19,10 +19,10 @@
</span>
}
<button type="button" class="btn btn-outline-primary" (click)="copy(link)">
@if (copied !== link.id) {
@if (copied() !== link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
}
@if (copied === link.id) {
@if (copied() === link.id) {
<i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
@@ -36,7 +36,7 @@
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied() === link.id" i18n>Copied!</span>
</li>
}
</ul>
@@ -44,7 +44,7 @@
<div class="modal-footer">
<div class="input-group w-100">
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion()" [(ngModel)]="useArchiveVersion">
<label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
</div>
</div>
@@ -55,11 +55,11 @@
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
<button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
@if (loading) {
<button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading()">
@if (loading()) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
}
@if (!loading) {
@if (!loading()) {
<i-bs name="plus"></i-bs>
}
<ng-container i18n>Create</ng-container>
@@ -4,12 +4,7 @@ import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
@@ -56,7 +51,7 @@ describe('ShareLinksDialogComponent', () => {
it('should support refresh to retrieve links', () => {
const getSpy = jest.spyOn(shareLinkService, 'getLinksForDocument')
component.documentId = 99
component.documentId.set(99)
const now = new Date()
const expiration7days = new Date()
@@ -88,7 +83,7 @@ describe('ShareLinksDialogComponent', () => {
fixture.detectChanges()
expect(component.shareLinks).toHaveLength(2)
expect(component.shareLinks()).toHaveLength(2)
})
it('should show error on refresh if needed', () => {
@@ -96,16 +91,17 @@ describe('ShareLinksDialogComponent', () => {
jest
.spyOn(shareLinkService, 'getLinksForDocument')
.mockReturnValueOnce(throwError(() => new Error('Unable to get links')))
component.documentId = 99
component.documentId.set(99)
component.ngOnInit()
fixture.detectChanges()
expect(toastSpy).toHaveBeenCalled()
})
it('should support link creation then refresh & copy url', fakeAsync(() => {
it('should support link creation then refresh & copy url', () => {
jest.useFakeTimers()
const createSpy = jest.spyOn(shareLinkService, 'createLinkForDocument')
component.documentId = 99
component.documentId.set(99)
component.expirationDays = 7
component.useArchiveVersion = false
@@ -126,16 +122,17 @@ describe('ShareLinksDialogComponent', () => {
expiration: expiration.toISOString(),
})
fixture.detectChanges()
tick(3000)
jest.advanceTimersByTime(3000)
expect(refreshSpy).toHaveBeenCalled()
expect(copySpy).toHaveBeenCalled()
expect(component.copied).toEqual(1)
tick(100) // copy timeout
}))
expect(component.copied()).toEqual(1)
jest.advanceTimersByTime(100) // copy timeout
jest.useRealTimers()
})
it('should show error on link creation if needed', () => {
component.documentId = 99
component.documentId.set(99)
component.expirationDays = 7
const expiration = new Date()
@@ -227,7 +224,7 @@ describe('ShareLinksDialogComponent', () => {
})
it('should disable archive switch & option if no archive available', (done) => {
component.hasArchiveVersion = false
component.hasArchiveVersion.set(false)
component.ngOnInit()
fixture.detectChanges()
expect(component.useArchiveVersion).toBeFalsy()
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, OnInit, inject } from '@angular/core'
import { Component, OnInit, effect, inject, input, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -27,55 +27,40 @@ export class ShareLinksDialogComponent implements OnInit {
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
@Input()
title = $localize`Share Links`
_documentId: number
@Input()
set documentId(id: number) {
if (id !== undefined) {
this._documentId = id
this.refresh()
}
}
private _hasArchiveVersion: boolean = true
@Input()
set hasArchiveVersion(value: boolean) {
this._hasArchiveVersion = value
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this._hasArchiveVersion
}
shareLinks: ShareLink[]
loading: boolean = false
copied: number
readonly title = input($localize`Share Links`)
readonly documentId = signal<number>(undefined)
readonly hasArchiveVersion = signal(true)
readonly shareLinks = signal<ShareLink[]>(undefined)
readonly loading = signal(false)
readonly copied = signal<number>(undefined)
expirationDays: number = 7
useArchiveVersion: boolean = true
constructor() {
effect(() => {
const id = this.documentId()
if (id !== undefined) this.refresh()
})
effect(() => {
this.useArchiveVersion = this.hasArchiveVersion()
})
}
ngOnInit(): void {
if (this._documentId !== undefined) this.refresh()
if (this.documentId() !== undefined) this.refresh()
}
refresh() {
if (this._documentId === undefined) return
this.loading = true
if (this.documentId() === undefined) return
this.loading.set(true)
this.shareLinkService
.getLinksForDocument(this._documentId)
.getLinksForDocument(this.documentId())
.pipe(first())
.subscribe({
next: (results) => {
this.loading = false
this.shareLinks = results
this.loading.set(false)
this.shareLinks.set(results)
},
error: (e) => {
this.toastService.showError(
@@ -104,9 +89,9 @@ export class ShareLinksDialogComponent implements OnInit {
copy(link: ShareLink) {
const success = this.clipboard.copy(this.getShareUrl(link))
if (success) {
this.copied = link.id
this.copied.set(link.id)
setTimeout(() => {
this.copied = null
this.copied.set(null)
}, 3000)
}
}
@@ -138,23 +123,23 @@ export class ShareLinksDialogComponent implements OnInit {
expiration = new Date()
expiration.setDate(expiration.getDate() + this.expirationDays)
}
this.loading = true
this.loading.set(true)
this.shareLinkService
.createLinkForDocument(
this._documentId,
this.documentId(),
this.useArchiveVersion ? FileVersion.Archive : FileVersion.Original,
expiration
)
.subscribe({
next: (result) => {
this.loading = false
this.loading.set(false)
setTimeout(() => {
this.copy(result)
}, 10)
this.refresh()
},
error: (e) => {
this.loading = false
this.loading.set(false)
this.toastService.showError($localize`Error creating link`, 10000, e)
},
})
@@ -1,6 +1,6 @@
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled || loading || (suggestions && !aiEnabled)">
@if (loading) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
@@ -11,34 +11,34 @@
}
</button>
@if (aiEnabled) {
@if (aiEnabled()) {
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled || loading || !suggestions" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
<span class="visually-hidden" i18n>Show suggestions</span>
</button>
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (!suggestions?.suggested_tags && !suggestions?.suggested_document_types && !suggestions?.suggested_correspondents) {
@if (!suggestions()?.suggested_tags && !suggestions()?.suggested_document_types && !suggestions()?.suggested_correspondents) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
}
@if (suggestions?.suggested_tags.length > 0) {
@if (suggestions()?.suggested_tags.length > 0) {
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
@for (tag of suggestions.suggested_tags; track tag) {
@for (tag of suggestions().suggested_tags; track tag) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
}
}
@if (suggestions?.suggested_document_types.length > 0) {
@if (suggestions()?.suggested_document_types.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
@for (type of suggestions.suggested_document_types; track type) {
@for (type of suggestions().suggested_document_types; track type) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
}
}
@if (suggestions?.suggested_correspondents.length > 0) {
@if (suggestions()?.suggested_correspondents.length > 0) {
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
@for (correspondent of suggestions.suggested_correspondents; track correspondent) {
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
}
}
@@ -22,25 +22,25 @@ describe('SuggestionsDropdownComponent', () => {
})
it('should calculate totalSuggestions', () => {
component.suggestions = {
fixture.componentRef.setInput('suggestions', {
suggested_correspondents: ['John Doe'],
suggested_tags: ['Tag1', 'Tag2'],
suggested_document_types: ['Type1'],
}
})
expect(component.totalSuggestions).toBe(4)
})
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
jest.spyOn(component.getSuggestions, 'emit')
component.suggestions = null
fixture.componentRef.setInput('suggestions', null)
component.clickSuggest()
expect(component.getSuggestions.emit).toHaveBeenCalled()
})
it('should not emit getSuggestions when disabled', () => {
jest.spyOn(component.getSuggestions, 'emit')
component.disabled = true
component.suggestions = null
fixture.componentRef.setInput('disabled', true)
fixture.componentRef.setInput('suggestions', null)
fixture.detectChanges()
component.clickSuggest()
@@ -50,13 +50,13 @@ describe('SuggestionsDropdownComponent', () => {
})
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
component.aiEnabled = true
fixture.componentRef.setInput('aiEnabled', true)
fixture.detectChanges()
component.suggestions = {
fixture.componentRef.setInput('suggestions', {
suggested_correspondents: [],
suggested_tags: [],
suggested_document_types: [],
}
})
component.clickSuggest()
expect(component.dropdown.open).toBeTruthy()
})
@@ -1,9 +1,9 @@
import {
Component,
EventEmitter,
Input,
Output,
ViewChild,
input,
} from '@angular/core'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -20,18 +20,10 @@ export class SuggestionsDropdownComponent {
public popperOptions = pngxPopperOptions
@ViewChild('dropdown') dropdown: NgbDropdown
@Input()
suggestions: DocumentSuggestions = null
@Input()
aiEnabled: boolean = false
@Input()
loading: boolean = false
@Input()
disabled: boolean = false
readonly suggestions = input<DocumentSuggestions>(null)
readonly aiEnabled = input(false)
readonly loading = input(false)
readonly disabled = input(false)
@Output()
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
@@ -48,14 +40,14 @@ export class SuggestionsDropdownComponent {
public clickSuggest(): void {
if (
this.disabled ||
this.loading ||
(this.suggestions && !this.aiEnabled)
this.disabled() ||
this.loading() ||
(this.suggestions() && !this.aiEnabled())
) {
return
}
if (!this.suggestions) {
if (!this.suggestions()) {
this.getSuggestions.emit(this)
} else {
this.dropdown?.toggle()
@@ -64,9 +56,9 @@ export class SuggestionsDropdownComponent {
get totalSuggestions(): number {
return (
this.suggestions?.suggested_correspondents?.length +
this.suggestions?.suggested_tags?.length +
this.suggestions?.suggested_document_types?.length || 0
this.suggestions()?.suggested_correspondents?.length +
this.suggestions()?.suggested_tags?.length +
this.suggestions()?.suggested_document_types?.length || 0
)
}
}
@@ -3,7 +3,7 @@
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (!status) {
@if (!status()) {
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@@ -21,25 +21,25 @@
<dl class="card-text">
<dt i18n>Paperless-ngx Version</dt>
<dd>
{{status.pngx_version}}
@if (versionMismatch) {
{{status().pngx_version}}
@if (versionMismatch()) {
<button class="btn btn-sm d-inline align-items-center btn-dark text-uppercase small" [ngbPopover]="versionPopover" triggers="click mouseenter:mouseleave">
<i-bs name="exclamation-triangle-fill" class="text-danger lh-1"></i-bs>
</button>
}
<ng-template #versionPopover>
Frontend version: {{frontendVersion}}<br>
Backend version: {{status.pngx_version}}
Backend version: {{status().pngx_version}}
</ng-template>
</dd>
<dt i18n>Install Type</dt>
<dd>{{status.install_type}}</dd>
<dd>{{status().install_type}}</dd>
<dt i18n>Server OS</dt>
<dd>{{status.server_os}}</dd>
<dd>{{status().server_os}}</dd>
<dt i18n>Media Storage</dt>
<dd>
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status.storage.total" [value]="status.storage.total - status.storage.available"></ngb-progressbar>
<span class="small">{{status.storage.available | fileSize}} <ng-container i18n>available</ng-container> ({{status.storage.total | fileSize}} <ng-container i18n>total</ng-container>)</span>
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status().storage.total" [value]="status().storage.total - status().storage.available"></ngb-progressbar>
<span class="small">{{status().storage.available | fileSize}} <ng-container i18n>available</ng-container> ({{status().storage.total | fileSize}} <ng-container i18n>total</ng-container>)</span>
</dd>
</dl>
</div>
@@ -54,39 +54,39 @@
<div class="card-body">
<dl class="card-text">
<dt i18n>Type</dt>
<dd>{{status.database.type}}</dd>
<dd>{{status().database.type}}</dd>
<dt i18n>Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave">
{{status.database.status}}
@if (status.database.status === 'OK') {
{{status().database.status}}
@if (status().database.status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
<ng-template #databaseStatus>
@if (status.database.status === 'OK') {
{{status.database.url}}
@if (status().database.status === 'OK') {
{{status().database.url}}
} @else {
{{status.database.url}}: {{status.database.error}}
{{status().database.url}}: {{status().database.error}}
}
</ng-template>
</dd>
<dt i18n>Migration Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave">
@if (status.database.migration_status.unapplied_migrations.length === 0) {
@if (status().database.migration_status.unapplied_migrations.length === 0) {
<ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
<ng-container>{{status().database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
}
<ng-template #migrationStatus>
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
@if (status.database.migration_status.unapplied_migrations.length > 0) {
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status().database.migration_status.latest_migration}}</span>
@if (status().database.migration_status.unapplied_migrations.length > 0) {
<h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
<ul>
@for (migration of status.database.migration_status.unapplied_migrations; track migration) {
@for (migration of status().database.migration_status.unapplied_migrations; track migration) {
<li class="font-monospace small">{{migration}}</li>
}
</ul>
@@ -109,60 +109,60 @@
<dt i18n>Redis Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') {
{{status().tasks.redis_status}}
@if (status().tasks.redis_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
<ng-template #redisStatus>
@if (status.tasks.redis_status === 'OK') {
{{status.tasks.redis_url}}
@if (status().tasks.redis_status === 'OK') {
{{status().tasks.redis_url}}
} @else {
{{status.tasks.redis_url}}: {{status.tasks.redis_error}}
{{status().tasks.redis_url}}: {{status().tasks.redis_error}}
}
</ng-template>
</dd>
<dt i18n>Celery Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') {
{{status().tasks.celery_status}}
@if (status().tasks.celery_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.celery_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.celery_status === SystemStatusItemStatus.WARNING"></i-bs>
[class.text-danger]="status().tasks.celery_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.celery_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</button>
<ng-template #celeryStatus>
@if (status.tasks.celery_status === 'OK') {
{{status.tasks.celery_url}}
@if (status().tasks.celery_status === 'OK') {
{{status().tasks.celery_url}}
} @else {
{{status.tasks.celery_error}}
{{status().tasks.celery_error}}
}
</ng-template>
</dd>
<dt i18n>Recent Task Activity <span class="small text-muted fw-light">({{status.tasks.summary.days}} days)</span></dt>
<dt i18n>Recent Task Activity <span class="small text-muted fw-light">({{status().tasks.summary.days}} days)</span></dt>
<dd class="mb-0">
@if (status.tasks.summary.total_count > 0) {
@if (status().tasks.summary.total_count > 0) {
<ul class="list-group border-light mt-2">
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Total</ng-container>:</span>
<span class="badge bg-light rounded-pill">{{status.tasks.summary.total_count}}</span>
<span class="badge bg-light rounded-pill">{{status().tasks.summary.total_count}}</span>
</li>
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Successful</ng-container>:</span>
<span class="badge bg-primary rounded-pill">{{status.tasks.summary.success_count}}</span>
<span class="badge bg-primary rounded-pill">{{status().tasks.summary.success_count}}</span>
</li>
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Failed</ng-container>:</span>
<span class="badge bg-danger rounded-pill">{{status.tasks.summary.failure_count}}</span>
<span class="badge bg-danger rounded-pill">{{status().tasks.summary.failure_count}}</span>
</li>
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Pending</ng-container>:</span>
<span class="badge bg-warning rounded-pill">{{status.tasks.summary.pending_count}}</span>
<span class="badge bg-warning rounded-pill">{{status().tasks.summary.pending_count}}</span>
</li>
</ul>
} @else {
@@ -184,8 +184,8 @@
<dt i18n>Search Index</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.index_status}}
@if (status.tasks.index_status === 'OK') {
{{status().tasks.index_status}}
@if (status().tasks.index_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
@@ -193,26 +193,26 @@
</button>
</dd>
<ng-template #indexStatus>
@if (status.tasks.index_status === 'OK') {
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
@if (status().tasks.index_status === 'OK') {
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.index_last_modified | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.index_error}}</span>
}
</ng-template>
<dt i18n>Classifier</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.classifier_status}}
@if (status.tasks.classifier_status === 'OK') {
@if (isStale(status.tasks.classifier_last_trained)) {
{{status().tasks.classifier_status}}
@if (status().tasks.classifier_status === 'OK') {
@if (isStale(status().tasks.classifier_last_trained)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
[class.text-danger]="status().tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@@ -227,26 +227,26 @@
}
</dd>
<ng-template #classifierStatus>
@if (status.tasks.classifier_status === 'OK') {
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
@if (status().tasks.classifier_status === 'OK') {
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.classifier_last_trained | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.classifier_error}}</span>
}
</ng-template>
<dt i18n>Sanity Checker</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.sanity_check_status}}
@if (status.tasks.sanity_check_status === 'OK') {
@if (isStale(status.tasks.sanity_check_last_run)) {
{{status().tasks.sanity_check_status}}
@if (status().tasks.sanity_check_status === 'OK') {
@if (isStale(status().tasks.sanity_check_last_run)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
[class.text-danger]="status().tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@@ -261,16 +261,16 @@
}
</dd>
<ng-template #sanityCheckerStatus>
@if (status.tasks.sanity_check_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_last_run | customDate:'medium'}}</span>
@if (status().tasks.sanity_check_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.sanity_check_last_run | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.sanity_check_error}}</span>
}
</ng-template>
<dt i18n>WebSocket Connection</dt>
<dd>
<span class="btn btn-sm pe-none align-items-center btn-dark text-uppercase small">
@if (status.websocket_connected === 'OK') {
@if (status().websocket_connected === 'OK') {
<ng-container i18n>OK</ng-container>
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
@@ -283,18 +283,18 @@
<dt i18n>AI Index</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="llmIndexStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.llmindex_status}}
@if (status.tasks.llmindex_status === 'OK') {
@if (isStale(status.tasks.llmindex_last_modified)) {
{{status().tasks.llmindex_status}}
@if (status().tasks.llmindex_status === 'OK') {
@if (isStale(status().tasks.llmindex_last_modified)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.llmindex_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.llmindex_status === SystemStatusItemStatus.WARNING"
[class.text-muted]="status.tasks.llmindex_status === SystemStatusItemStatus.DISABLED"></i-bs>
[class.text-danger]="status().tasks.llmindex_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.llmindex_status === SystemStatusItemStatus.WARNING"
[class.text-muted]="status().tasks.llmindex_status === SystemStatusItemStatus.DISABLED"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@@ -309,10 +309,10 @@
}
</dd>
<ng-template #llmIndexStatus>
@if (status.tasks.llmindex_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_last_modified | customDate:'medium'}}</span>
@if (status().tasks.llmindex_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.llmindex_last_modified | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.llmindex_error}}</span>
}
</ng-template>
}
@@ -325,10 +325,10 @@
</div>
<div class="modal-footer">
<button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
@if (!copied) {
@if (!copied()) {
<i-bs name="clipboard-fill" class="me-1"></i-bs>
}
@if (copied) {
@if (copied()) {
<i-bs name="clipboard-check-fill" class="me-1"></i-bs>
}
<ng-container i18n>Copy</ng-container>
@@ -16,12 +16,7 @@ jest.mock('src/environments/environment', () => ({
import { Clipboard } from '@angular/cdk/clipboard'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
@@ -106,7 +101,7 @@ describe('SystemStatusDialogComponent', () => {
fixture = TestBed.createComponent(SystemStatusDialogComponent)
component = fixture.componentInstance
component.status = status
component.status.set({ ...status })
clipboard = TestBed.inject(Clipboard)
tasksService = TestBed.inject(TasksService)
systemStatusService = TestBed.inject(SystemStatusService)
@@ -126,16 +121,18 @@ describe('SystemStatusDialogComponent', () => {
expect(closeSpy).toHaveBeenCalled()
})
it('should copy the system status to clipboard', fakeAsync(() => {
it('should copy the system status to clipboard', () => {
jest.useFakeTimers()
jest.spyOn(clipboard, 'copy')
component.copy()
expect(clipboard.copy).toHaveBeenCalledWith(
JSON.stringify(component.status, null, 4)
JSON.stringify(component.status(), null, 4)
)
expect(component.copied).toBeTruthy()
tick(3000)
expect(component.copied).toBeFalsy()
}))
expect(component.copied()).toBeTruthy()
jest.advanceTimersByTime(3000)
expect(component.copied()).toBeFalsy()
jest.useRealTimers()
})
it('should calculate if date is stale', () => {
const date = new Date()
@@ -180,25 +177,28 @@ describe('SystemStatusDialogComponent', () => {
it('shoduld handle version mismatch', () => {
component.frontendVersion = '2.4.2'
component.ngOnInit()
expect(component.versionMismatch).toBeTruthy()
expect(component.status.pngx_version).toContain('(frontend: 2.4.2)')
expect(component.versionMismatch()).toBeTruthy()
expect(component.status().pngx_version).toContain('(frontend: 2.4.2)')
component.frontendVersion = '2.4.3'
component.status.pngx_version = '2.4.3'
component.status.update((status) => ({
...status,
pngx_version: '2.4.3',
}))
component.ngOnInit()
expect(component.versionMismatch).toBeFalsy()
expect(component.versionMismatch()).toBeFalsy()
})
it('should update websocket connection status', () => {
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
expect(component.status().websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
websocketSubject.next(false)
expect(component.status.websocket_connected).toEqual(
expect(component.status().websocket_connected).toEqual(
SystemStatusItemStatus.ERROR
)
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
expect(component.status().websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
})
@@ -1,5 +1,12 @@
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import {
Component,
Input,
OnDestroy,
OnInit,
inject,
signal,
} from '@angular/core'
import {
NgbActiveModal,
NgbModalModule,
@@ -50,13 +57,11 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
public SystemStatusItemStatus = SystemStatusItemStatus
public PaperlessTaskType = PaperlessTaskType
public status: SystemStatus
@Input() status = signal<SystemStatus>(undefined)
public frontendVersion: string = environment.version
public versionMismatch: boolean = false
public copied: boolean = false
private runningTasks: Set<PaperlessTaskType> = new Set()
readonly versionMismatch = signal(false)
readonly copied = signal(false)
readonly runningTasks = signal<Set<PaperlessTaskType>>(new Set())
private unsubscribeNotifier: Subject<any> = new Subject()
get currentUserIsSuperUser(): boolean {
@@ -68,25 +73,24 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
}
public ngOnInit() {
this.versionMismatch =
const status = this.status()
this.versionMismatch.set(
environment.production &&
this.status.pngx_version &&
this.frontendVersion &&
this.status.pngx_version !== this.frontendVersion
if (this.versionMismatch) {
this.status.pngx_version = `${this.status.pngx_version} (frontend: ${this.frontendVersion})`
status.pngx_version &&
this.frontendVersion &&
status.pngx_version !== this.frontendVersion
)
if (this.versionMismatch()) {
this.status.set({
...status,
pngx_version: `${status.pngx_version} (frontend: ${this.frontendVersion})`,
})
}
this.status.websocket_connected = this.websocketStatusService.isConnected()
? SystemStatusItemStatus.OK
: SystemStatusItemStatus.ERROR
this.updateWebsocketStatus(this.websocketStatusService.isConnected())
this.websocketStatusService
.onConnectionStatus()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((connected) => {
this.status.websocket_connected = connected
? SystemStatusItemStatus.OK
: SystemStatusItemStatus.ERROR
})
.subscribe((connected) => this.updateWebsocketStatus(connected))
}
public close() {
@@ -94,10 +98,10 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
}
public copy() {
this.clipboard.copy(JSON.stringify(this.status, null, 4))
this.copied = true
this.clipboard.copy(JSON.stringify(this.status(), null, 4))
this.copied.set(true)
setTimeout(() => {
this.copied = false
this.copied.set(false)
}, 3000)
}
@@ -108,23 +112,26 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
}
public isRunning(taskName: PaperlessTaskType): boolean {
return this.runningTasks.has(taskName)
return this.runningTasks().has(taskName)
}
public runTask(taskName: PaperlessTaskType) {
this.runningTasks.add(taskName)
this.setTaskRunning(taskName, true)
this.toastService.showInfo(`Task ${taskName} started`)
this.tasksService.run(taskName).subscribe({
next: () => {
this.runningTasks.delete(taskName)
this.setTaskRunning(taskName, false)
this.systemStatusService.get().subscribe({
next: (status) => {
Object.assign(this.status, status)
next: (statusUpdate) => {
this.status.set({
...this.status(),
...statusUpdate,
})
},
})
},
error: (err) => {
this.runningTasks.delete(taskName)
this.setTaskRunning(taskName, false)
this.toastService.showError(
`Failed to start task ${taskName}, see the logs for more details`,
err
@@ -133,6 +140,25 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
})
}
private updateWebsocketStatus(connected: boolean): void {
this.status.set({
...this.status(),
websocket_connected: connected
? SystemStatusItemStatus.OK
: SystemStatusItemStatus.ERROR,
})
}
private setTaskRunning(taskName: PaperlessTaskType, running: boolean): void {
const runningTasks = new Set(this.runningTasks())
if (running) {
runningTasks.add(taskName)
} else {
runningTasks.delete(taskName)
}
this.runningTasks.set(runningTasks)
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
@@ -1,13 +1,13 @@
@if (tag) {
@if (showParents && tag.parent) {
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
@if (tag()) {
@if (showParents() && tag().parent) {
<pngx-tag [tagID]="tag().parent" [clickable]="clickable()" [linkTitle]="linkTitle()"></pngx-tag>
&nbsp;&gt;&nbsp;
}
@if (!clickable) {
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
@if (!clickable()) {
<span class="badge" [style.background]="tag().color" [style.color]="tag().text_color">{{tag().name}}</span>
}
@if (clickable) {
<a [title]="linkTitle" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a>
@if (clickable()) {
<a [title]="linkTitle()" class="badge" [style.background]="tag().color" [style.color]="tag().text_color">{{tag().name}}</a>
}
} @else if (loading) {
<span class="placeholder-glow">
@@ -16,10 +16,10 @@
</span>
</span>
} @else {
@if (!clickable) {
@if (!clickable()) {
<span class="badge private" i18n>Private</span>
}
@if (clickable) {
<a [title]="linkTitle" class="badge private" i18n>Private</a>
@if (clickable()) {
<a [title]="linkTitle()" class="badge private" i18n>Private</a>
}
}
@@ -37,7 +37,7 @@ describe('TagComponent', () => {
})
it('should create tag with background color', () => {
component.tag = tag
component.tag.set(tag)
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('span')).nativeElement.style
@@ -52,10 +52,10 @@ describe('TagComponent', () => {
})
it('should support clickable option', () => {
component.tag = tag
component.tag.set(tag)
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('a.badge'))).toBeNull()
component.clickable = true
fixture.componentRef.setInput('clickable', true)
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('a.badge'))).not.toBeNull()
})
@@ -64,8 +64,9 @@ describe('TagComponent', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const getCachedSpy = jest.spyOn(tagService, 'getCached')
getCachedSpy.mockReturnValue(of(tag))
component.tagID = 1
fixture.componentRef.setInput('tagID', 1)
fixture.detectChanges()
expect(getCachedSpy).toHaveBeenCalledWith(1)
expect(component.tag).toEqual(tag)
expect(component.tag()).toEqual(tag)
})
})
@@ -1,4 +1,4 @@
import { Component, inject, Input } from '@angular/core'
import { Component, effect, inject, input, model } from '@angular/core'
import { Tag } from 'src/app/data/tag'
import {
PermissionAction,
@@ -16,44 +16,33 @@ export class TagComponent {
private permissionsService = inject(PermissionsService)
private tagService = inject(TagService)
private _tag: Tag
private _tagID: number
readonly tag = model<Tag>(null)
readonly tagID = input<number>(undefined)
readonly linkTitle = input('')
readonly clickable = input(false, {
transform: (value: boolean | string) =>
value === '' || value === true || value === 'true',
})
readonly showParents = input(false)
@Input()
public set tag(tag: Tag) {
this._tag = tag
}
public get tag(): Tag {
return this._tag
}
@Input()
set tagID(tagID: number) {
if (tagID !== this._tagID) {
this._tagID = tagID
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService.getCached(this._tagID).subscribe((tag) => {
this.tag = tag
})
constructor() {
effect(() => {
const tagID = this.tagID()
if (tagID) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService.getCached(tagID).subscribe((tag) => {
this.tag.set(tag)
})
}
}
}
})
}
@Input()
linkTitle: string = ''
@Input()
clickable: boolean = false
@Input()
showParents: boolean = false
public get loading(): boolean {
return this.tagService.loading
}
@@ -34,10 +34,10 @@
<div class="row">
<div class="col offset-sm-3">
<button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)">
@if (!copied) {
@if (!copied()) {
<i-bs name="clipboard" class="me-1"></i-bs>
}
@if (copied) {
@if (copied()) {
<i-bs name="clipboard-check" class="me-1"></i-bs>
}
<ng-container i18n>Copy Raw Error</ng-container>
@@ -1,11 +1,4 @@
import {
ComponentFixture,
discardPeriodicTasks,
fakeAsync,
flush,
TestBed,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -48,28 +41,25 @@ describe('ToastComponent', () => {
expect(component).toBeTruthy()
})
it('should countdown toast', fakeAsync(() => {
it('should countdown toast', () => {
jest.useFakeTimers()
component.toast = toast2
fixture.detectChanges()
component.onShown(toast2)
tick(5000)
jest.advanceTimersByTime(5000)
expect(component.toast.delayRemaining).toEqual(0)
flush()
discardPeriodicTasks()
}))
jest.useRealTimers()
})
it('should show an error if given with toast', fakeAsync(() => {
it('should show an error if given with toast', () => {
component.toast = toast1
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
expect(fixture.nativeElement.textContent).toContain('Error 1 content')
})
flush()
discardPeriodicTasks()
}))
it('should show error details, support copy', fakeAsync(() => {
it('should show error details, support copy', () => {
component.toast = toast2
fixture.detectChanges()
@@ -81,10 +71,7 @@ describe('ToastComponent', () => {
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyError(toast2.error)
expect(copySpy).toHaveBeenCalled()
flush()
discardPeriodicTasks()
}))
})
it('should parse error text, add ellipsis', () => {
expect(component.getErrorText(toast2.error)).toEqual(
@@ -1,6 +1,13 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { DecimalPipe } from '@angular/common'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import {
NgbProgressbarModule,
NgbToastModule,
@@ -22,6 +29,7 @@ import { Toast } from 'src/app/services/toast.service'
})
export class ToastComponent {
private clipboard = inject(Clipboard)
readonly copied = signal(false)
@Input() toast: Toast
@@ -31,8 +39,6 @@ export class ToastComponent {
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
public copied: boolean = false
onShown(toast: Toast) {
if (!this.autohide) return
@@ -62,9 +68,9 @@ export class ToastComponent {
public copyError(error: any) {
this.clipboard.copy(JSON.stringify(error))
this.copied = true
this.copied.set(true)
setTimeout(() => {
this.copied = false
this.copied.set(false)
}, 3000)
}
@@ -1,3 +1,3 @@
@for (toast of toasts; track toast.id) {
@for (toast of toasts(); track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
}
@@ -22,7 +22,7 @@ describe('ToastsComponent', () => {
let component: ToastsComponent
let fixture: ComponentFixture<ToastsComponent>
let toastService: ToastService
let toastSubject: Subject<Toast> = new Subject()
let toastSubject: Subject<Toast>
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -33,10 +33,11 @@ describe('ToastsComponent', () => {
],
}).compileComponents()
fixture = TestBed.createComponent(ToastsComponent)
toastService = TestBed.inject(ToastService)
toastSubject = new Subject()
jest.replaceProperty(toastService, 'showToast', toastSubject)
fixture = TestBed.createComponent(ToastsComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -47,25 +48,15 @@ describe('ToastsComponent', () => {
})
it('should close toast', () => {
component.toasts = [toast]
toastSubject.next(toast)
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast()
expect(component.toasts).toEqual([])
expect(component.toasts()).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast)
})
it('should unsubscribe', () => {
const unsubscribeSpy = jest.spyOn(
(component as any).subscription,
'unsubscribe'
)
component.ngOnDestroy()
expect(unsubscribeSpy).toHaveBeenCalled()
})
it('should subscribe to toastService', () => {
component.ngOnInit()
it('should update from toastService', () => {
toastSubject.next(toast)
expect(component.toasts).toEqual([toast])
expect(component.toasts()).toEqual([toast])
})
})
@@ -1,10 +1,11 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NgbAccordionModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription } from 'rxjs'
import { map, merge, Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component'
@@ -19,25 +20,21 @@ import { ToastComponent } from '../toast/toast.component'
NgxBootstrapIconsModule,
],
})
export class ToastsComponent implements OnInit, OnDestroy {
export class ToastsComponent {
toastService = inject(ToastService)
private subscription: Subscription
private readonly closedToast = new Subject<void>()
public toasts: Toast[] = [] // array to force change detection
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.showToast.subscribe((toast) => {
this.toasts = toast ? [toast] : []
})
}
readonly toasts = toSignal(
merge(
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))),
this.closedToast.pipe(map(() => [] as Toast[]))
),
{ initialValue: [] as Toast[] }
)
closeToast() {
this.toastService.closeToast(this.toasts[0])
this.toasts = []
this.toastService.closeToast(this.toasts()[0])
this.closedToast.next()
}
}