This commit is contained in:
shamoon
2026-07-08 19:28:46 -07:00
parent 03fbf2f866
commit b3f0985ed4
21 changed files with 108 additions and 152 deletions
@@ -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()
@@ -13,33 +13,12 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
documentService = inject(DocumentService)
private documentIDSignal = signal<number>(undefined)
private showPDFNoteSignal = signal(true)
private rotationSignal = signal(0)
public get documentID(): number {
return this.documentIDSignal()
}
public set documentID(documentID: number) {
this.documentIDSignal.set(documentID)
}
public get showPDFNote(): boolean {
return this.showPDFNoteSignal()
}
public set showPDFNote(showPDFNote: boolean) {
this.showPDFNoteSignal.set(showPDFNote)
}
// animation is better if we dont normalize yet
public get rotation(): number {
return this.rotationSignal()
}
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
}
@@ -49,6 +28,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
}
rotate(clockwise: boolean = true) {
this.rotationSignal.update((rotation) => rotation + (clockwise ? 90 : -90))
this.rotation.update((rotation) => rotation + (clockwise ? 90 : -90))
}
}
@@ -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">
@@ -38,17 +38,9 @@ const IMAP_SECURITY_OPTIONS = [
})
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
testActive: boolean = false
private testResultSignal = signal<string>(undefined)
readonly testResult = signal<string>(undefined)
alertTimeout
get testResult(): string {
return this.testResultSignal()
}
set testResult(testResult: string) {
this.testResultSignal.set(testResult)
}
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
constructor() {
@@ -85,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(
@@ -95,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`
}
@@ -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,10 +23,10 @@
<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">
<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>
}
@@ -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, signal } 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,36 +17,9 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
private documentService = inject(DocumentService)
private toastService = inject(ToastService)
private documentIdsSignal = signal<number[]>(undefined)
private hasArchiveVersionSignal = signal(true)
private useArchiveVersionSignal = signal(true)
@Input()
get documentIds(): number[] {
return this.documentIdsSignal()
}
set documentIds(documentIds: number[]) {
this.documentIdsSignal.set(documentIds)
}
get useArchiveVersion(): boolean {
return this.useArchiveVersionSignal()
}
set useArchiveVersion(useArchiveVersion: boolean) {
this.useArchiveVersionSignal.set(useArchiveVersion)
}
@Input()
set hasArchiveVersion(value: boolean) {
this.hasArchiveVersionSignal.set(value)
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this.hasArchiveVersionSignal()
}
readonly documentIds = signal<number[]>(undefined)
readonly hasArchiveVersion = signal(true)
readonly useArchiveVersion = signal(true)
public emailAddress: string = ''
public emailSubject: string = ''
@@ -55,17 +28,20 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
constructor() {
super()
this.loading.set(false)
effect(() => {
this.useArchiveVersion.set(this.hasArchiveVersion())
})
}
public emailDocuments() {
this.loading.set(true)
this.documentService
.emailDocuments(
this.documentIds,
this.documentIds(),
this.emailAddress,
this.emailSubject,
this.emailMessage,
this.useArchiveVersion
this.useArchiveVersion()
)
.subscribe({
next: () => {
@@ -79,7 +55,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
error: (e) => {
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)
@@ -34,6 +34,7 @@ describe('PageHeaderComponent', () => {
it('should set html title', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
fixture.componentRef.setInput('title', 'Foo Bar')
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
})
@@ -56,7 +56,7 @@ describe('ShareLinksDialogComponent', () => {
it('should support refresh to retrieve links', () => {
const getSpy = jest.spyOn(shareLinkService, 'getLinksForDocument')
fixture.componentRef.setInput('documentId', 99)
component.documentId.set(99)
const now = new Date()
const expiration7days = new Date()
@@ -96,7 +96,7 @@ describe('ShareLinksDialogComponent', () => {
jest
.spyOn(shareLinkService, 'getLinksForDocument')
.mockReturnValueOnce(throwError(() => new Error('Unable to get links')))
fixture.componentRef.setInput('documentId', 99)
component.documentId.set(99)
component.ngOnInit()
fixture.detectChanges()
@@ -105,7 +105,7 @@ describe('ShareLinksDialogComponent', () => {
it('should support link creation then refresh & copy url', fakeAsync(() => {
const createSpy = jest.spyOn(shareLinkService, 'createLinkForDocument')
fixture.componentRef.setInput('documentId', 99)
component.documentId.set(99)
component.expirationDays = 7
component.useArchiveVersion = false
@@ -135,7 +135,7 @@ describe('ShareLinksDialogComponent', () => {
}))
it('should show error on link creation if needed', () => {
fixture.componentRef.setInput('documentId', 99)
component.documentId.set(99)
component.expirationDays = 7
const expiration = new Date()
@@ -227,7 +227,7 @@ describe('ShareLinksDialogComponent', () => {
})
it('should disable archive switch & option if no archive available', (done) => {
fixture.componentRef.setInput('hasArchiveVersion', false)
component.hasArchiveVersion.set(false)
component.ngOnInit()
fixture.detectChanges()
expect(component.useArchiveVersion).toBeFalsy()
@@ -28,8 +28,8 @@ export class ShareLinksDialogComponent implements OnInit {
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
readonly title = input($localize`Share Links`)
readonly documentId = input<number>(undefined)
readonly hasArchiveVersion = input(true)
readonly documentId = signal<number>(undefined)
readonly hasArchiveVersion = signal(true)
readonly shareLinks = signal<ShareLink[]>(undefined)
readonly loading = signal(false)
readonly copied = signal<number>(undefined)
@@ -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)
})
})
@@ -2151,9 +2151,10 @@ export class DocumentDetailComponent
public openShareLinks() {
const modal = this.modalService.open(ShareLinksDialogComponent)
modal.componentInstance.documentId = this.document.id
modal.componentInstance.hasArchiveVersion =
modal.componentInstance.documentId.set(this.document.id)
modal.componentInstance.hasArchiveVersion.set(
this.metadata?.has_archive_version ?? !!this.document?.archived_file_name
)
}
get emailEnabled(): boolean {
@@ -2164,9 +2165,10 @@ export class DocumentDetailComponent
const modal = this.modalService.open(EmailDocumentDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.documentIds = [this.document.id]
modal.componentInstance.hasArchiveVersion =
modal.componentInstance.documentIds.set([this.document.id])
modal.componentInstance.hasArchiveVersion.set(
this.metadata?.has_archive_version ?? !!this.document?.archived_file_name
)
}
private tryRenderTiff() {
@@ -933,7 +933,7 @@ export class BulkEditorComponent
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
rotateDialog.documentID.set(Array.from(this.list.selected)[0])
rotateDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
@@ -1080,7 +1080,7 @@ export class BulkEditorComponent
const modal = this.modalService.open(EmailDocumentDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.documentIds = Array.from(this.list.selected)
modal.componentInstance.hasArchiveVersion = allHaveArchiveVersion
modal.componentInstance.documentIds.set(Array.from(this.list.selected))
modal.componentInstance.hasArchiveVersion.set(allHaveArchiveVersion)
}
}
@@ -67,15 +67,19 @@ describe('DocumentCardLargeComponent', () => {
fixture = TestBed.createComponent(DocumentCardLargeComponent)
component = fixture.componentInstance
component.document = { ...doc, tags: [...doc.tags], notes: [...doc.notes] }
fixture.componentRef.setInput('document', {
...doc,
tags: [...doc.tags],
notes: [...doc.notes],
})
fixture.detectChanges()
jest.useFakeTimers()
})
it('should show the card', () => {
expect(component.show).toBeTruthy()
expect(component.show()).toBeTruthy()
component.ngAfterViewInit()
expect(component.show).toBeTruthy()
expect(component.show()).toBeTruthy()
})
it('should display a document', () => {
@@ -90,34 +94,34 @@ describe('DocumentCardLargeComponent', () => {
it('should display search hits with colored score', () => {
// high
component.document = {
...component.document,
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: {
score: 0.9,
rank: 1,
highlights: 'cheesecake',
},
}
})
fixture.detectChanges()
let search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
expect(component.searchScoreClass).toEqual('success')
// medium
component.document = {
...component.document,
__search_hit__: { ...component.document.__search_hit__, score: 0.6 },
}
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: { ...component.document().__search_hit__, score: 0.6 },
})
fixture.detectChanges()
search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
expect(component.searchScoreClass).toEqual('warning')
// low
component.document = {
...component.document,
__search_hit__: { ...component.document.__search_hit__, score: 0.1 },
}
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: { ...component.document().__search_hit__, score: 0.1 },
})
fixture.detectChanges()
search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
@@ -125,29 +129,29 @@ describe('DocumentCardLargeComponent', () => {
})
it('should display note highlights', () => {
component.document = {
...component.document,
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: {
score: 0.9,
rank: 1,
note_highlights: '<span>bananas</span>',
},
}
})
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('bananas')
expect(component.searchNoteHighlights).toContain('<span>bananas</span>')
})
it('should fall back to document content when a search hit has no highlights', () => {
component.document = {
...component.document,
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: {
score: 0.9,
rank: 1,
highlights: '',
note_highlights: null,
},
}
})
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Cupcake ipsum')
@@ -46,15 +46,15 @@ describe('DocumentCardSmallComponent', () => {
fixture = TestBed.createComponent(DocumentCardSmallComponent)
component = fixture.componentInstance
component.document = Object.assign({}, doc)
fixture.componentRef.setInput('document', Object.assign({}, doc))
fixture.detectChanges()
jest.useFakeTimers()
})
it('should show the card', () => {
expect(component.show).toBeTruthy()
expect(component.show()).toBeTruthy()
component.ngAfterViewInit()
expect(component.show).toBeTruthy()
expect(component.show()).toBeTruthy()
})
it('should display page count', () => {
@@ -66,7 +66,10 @@ describe('DocumentCardSmallComponent', () => {
expect(
fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(5)
component.document = { ...component.document, tags: [1, 2] }
fixture.componentRef.setInput('document', {
...component.document(),
tags: [1, 2],
})
fixture.detectChanges()
expect(
fixture.debugElement.queryAll(By.directive(TagComponent))
@@ -74,7 +77,10 @@ describe('DocumentCardSmallComponent', () => {
})
it('should increase limit tags to 6 if no notes', () => {
component.document = { ...component.document, notes: [] }
fixture.componentRef.setInput('document', {
...component.document(),
notes: [],
})
fixture.detectChanges()
expect(
fixture.debugElement.queryAll(By.directive(TagComponent))
@@ -84,7 +90,10 @@ describe('DocumentCardSmallComponent', () => {
it('should clear hidden tag counter when tag count falls below the limit', () => {
expect(component.moreTags).toEqual(3)
component.document = { ...component.document, tags: [1, 2, 3, 4, 5, 6] }
fixture.componentRef.setInput('document', {
...component.document(),
tags: [1, 2, 3, 4, 5, 6],
})
fixture.detectChanges()
expect(component.moreTags).toBeNull()
@@ -414,6 +414,6 @@ describe('MailComponent', () => {
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.viewProcessedMail(mailRules[0] as MailRule)
const dialog = modal.componentInstance as any
expect(dialog.rule).toEqual(mailRules[0])
expect(dialog.rule()).toEqual(mailRules[0])
})
})
@@ -359,7 +359,7 @@ export class MailComponent
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.rule = rule
modal.componentInstance.rule.set(rule)
}
userCanEdit(obj: ObjectWithPermissions): boolean {
@@ -1,5 +1,5 @@
<div class="modal-header">
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6>
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule().name }}</em></h6>
<button class="btn btn-sm btn-link text-muted me-auto 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>
@@ -64,7 +64,7 @@ describe('ProcessedMailDialogComponent', () => {
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(ProcessedMailDialogComponent)
component = fixture.componentInstance
fixture.componentRef.setInput('rule', rule)
component.rule.set(rule)
})
afterEach(() => {
@@ -1,5 +1,5 @@
import { SlicePipe } from '@angular/common'
import { Component, inject, Input, OnInit, signal } from '@angular/core'
import { Component, inject, OnInit, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbActiveModal,
@@ -36,7 +36,7 @@ export class ProcessedMailDialogComponent implements OnInit {
private readonly processedMailService = inject(ProcessedMailService)
private readonly toastService = inject(ToastService)
private readonly ruleSignal = signal<MailRule>(undefined)
readonly rule = signal<MailRule>(undefined)
readonly processedMails = signal<ProcessedMail[]>([])
readonly loading = signal(true)
readonly toggleAllEnabled = signal(false)
@@ -44,15 +44,6 @@ export class ProcessedMailDialogComponent implements OnInit {
public page: number = 1
@Input()
get rule(): MailRule {
return this.ruleSignal()
}
set rule(rule: MailRule) {
this.ruleSignal.set(rule)
}
ngOnInit(): void {
this.loadProcessedMails()
}
@@ -65,7 +56,7 @@ export class ProcessedMailDialogComponent implements OnInit {
this.loading.set(true)
this.clearSelection()
this.processedMailService
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
.list(this.page, 50, 'processed_at', true, { rule: this.rule().id })
.subscribe((result) => {
this.processedMails.set(result.results)
this.loading.set(false)