mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-11 06:25:10 +00:00
Chorehancement: update to Angular v22, 'zoneless' / 'reactive' (#13114)
This commit is contained in:
@@ -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'
|
||||
|
||||
+7
-7
@@ -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>
|
||||
|
||||
+4
-4
@@ -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', () => {
|
||||
|
||||
+13
-15
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
+4
-11
@@ -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) {
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+6
-8
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+19
-18
@@ -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()
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+4
-10
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+3
-3
@@ -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()
|
||||
})
|
||||
|
||||
+8
-8
@@ -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',
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ export class CustomFieldEditDialogComponent
|
||||
}
|
||||
|
||||
get typeFieldDisabled(): boolean {
|
||||
return this.dialogMode === EditDialogMode.EDIT
|
||||
return this.dialogMode() === EditDialogMode.EDIT
|
||||
}
|
||||
|
||||
private updateSelectOptions() {
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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()
|
||||
})
|
||||
|
||||
+2
-2
@@ -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">
|
||||
|
||||
+10
-13
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+6
-6
@@ -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`
|
||||
}
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+3
-3
@@ -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()
|
||||
})
|
||||
|
||||
+17
-25
@@ -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() {
|
||||
|
||||
+3
-3
@@ -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()
|
||||
})
|
||||
|
||||
+3
-3
@@ -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))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+3
-3
@@ -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()
|
||||
})
|
||||
|
||||
+2
-2
@@ -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>
|
||||
}
|
||||
|
||||
+6
-6
@@ -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', () => {
|
||||
|
||||
+16
-17
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
+10
-10
@@ -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">
|
||||
|
||||
+8
-12
@@ -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(
|
||||
[]
|
||||
)
|
||||
|
||||
+44
-58
@@ -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 {
|
||||
|
||||
+5
-5
@@ -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>
|
||||
|
||||
+9
-8
@@ -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'
|
||||
|
||||
+14
-24
@@ -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)
|
||||
|
||||
+69
-39
@@ -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() {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
clearable="true"
|
||||
[items]="groups"
|
||||
[items]="groups()"
|
||||
multiple="true"
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
|
||||
+7
-12
@@ -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[] }
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
clearable="true"
|
||||
[items]="users"
|
||||
[items]="users()"
|
||||
multiple="true"
|
||||
bindLabel="username"
|
||||
bindValue="id"
|
||||
|
||||
+7
-12
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+7
-7
@@ -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>
|
||||
|
||||
+1
-1
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
+18
-15
@@ -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,
|
||||
|
||||
+2
-2
@@ -64,9 +64,9 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
{
|
||||
provide: SettingsService,
|
||||
useValue: {
|
||||
currentUser: {
|
||||
currentUser: () => ({
|
||||
id: currentUserID,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
|
||||
+3
-3
@@ -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
|
||||
)
|
||||
|
||||
+15
-15
@@ -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>
|
||||
|
||||
+18
-17
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+43
-32
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -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 }}
|
||||
|
||||
+14
-17
@@ -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/'
|
||||
|
||||
+14
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -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"
|
||||
>
|
||||
|
||||
+42
-58
@@ -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()
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
+24
-24
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -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>
|
||||
|
||||
+14
-17
@@ -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()
|
||||
|
||||
+29
-44
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
+11
-11
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -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()
|
||||
})
|
||||
|
||||
+12
-20
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-70
@@ -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>
|
||||
|
||||
+20
-20
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
+56
-30
@@ -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>
|
||||
>
|
||||
}
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user