Compare commits

...
10 Commits
54 changed files with 600 additions and 381 deletions
+140 -136
View File
File diff suppressed because it is too large Load Diff
@@ -37,7 +37,7 @@
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs"> <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
<li [ngbNavItem]="SettingsNavIDs.General"> <li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a> <a ngbNavLink i18n>General</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications' activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit() component.ngOnInit()
expect(component.activeNavID).toEqual(4) // Notifications expect(component.activeNavID()).toEqual(4) // Notifications
component.ngAfterViewInit() component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications') expect(scrollSpy).toHaveBeenCalledWith('#notifications')
}) })
@@ -142,7 +142,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService) private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService) private savedViewsService = inject(SavedViewService)
activeNavID: number readonly activeNavID = signal<number>(undefined)
settingsForm = new FormGroup({ settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null), bulkEditConfirmationDialogs: new FormControl(null),
@@ -283,7 +283,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section (navID) => navID.toLowerCase() == section
) )
if (navIDKey) { if (navIDKey) {
this.activeNavID = SettingsNavIDs[navIDKey] this.activeNavID.set(SettingsNavIDs[navIDKey])
} }
} }
}) })
@@ -386,7 +386,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()]) .navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => { .then((navigated) => {
if (!navigated && this.isDirty) { if (!navigated && this.isDirty) {
this.activeNavID = navChangeEvent.activeId this.activeNavID.set(navChangeEvent.activeId)
} else if (navigated && this.isDirty) { } else if (navigated && this.isDirty) {
this.initialize() this.initialize()
} }
@@ -1,5 +1,12 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common' import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core' import {
ChangeDetectorRef,
Component,
inject,
Input,
LOCALE_ID,
OnInit,
} from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs' import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -22,6 +29,7 @@ export class CustomFieldDisplayComponent
{ {
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType CustomFieldDataType = CustomFieldDataType
@@ -74,6 +82,7 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => { this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results this.customFields = r.results
this.init() this.init()
this.changeDetector.markForCheck()
}) })
} }
@@ -111,6 +120,7 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id)) .map((id) => result.results.find((d) => d.id === id))
.filter((d) => d) .filter((d) => d)
this.changeDetector.markForCheck()
}) })
} }
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => { it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any] component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']() component['updateUnusedFields']()
expect(component['unusedFields'].length).toEqual(1) expect(component['unusedFields']().length).toEqual(1)
expect(component['unusedFields'][0].name).toEqual('Field 2') expect(component['unusedFields']()[0].name).toEqual('Field 2')
}) })
it('should support getting data type label', () => { it('should support getting data type label', () => {
@@ -8,6 +8,7 @@ import {
ViewChild, ViewChild,
ViewChildren, ViewChildren,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -65,11 +66,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef> @ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = [] private customFields: CustomField[] = []
private unusedFields: CustomField[] = [] private readonly unusedFields = signal<CustomField[]>([])
private keyboardIndex: number private keyboardIndex: number
public get filteredFields(): CustomField[] { public get filteredFields(): CustomField[] {
return this.unusedFields.filter( return this.unusedFields().filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText) (f) => !this.filterText || matchesSearchText(f.name, this.filterText)
) )
} }
@@ -99,8 +100,10 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
} }
private updateUnusedFields() { private updateUnusedFields() {
this.unusedFields = this.customFields.filter( this.unusedFields.set(
(f) => !this.existingFields?.find((e) => e.field === f.id) this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
) )
} }
@@ -77,7 +77,7 @@
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<ng-select #fieldSelects <ng-select #fieldSelects
class="paperless-input-select" class="paperless-input-select"
[items]="customFields" [items]="customFields()"
[(ngModel)]="atom.field" [(ngModel)]="atom.field"
[disabled]="disabled" [disabled]="disabled"
bindLabel="name" bindLabel="name"
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
}) })
it('should initialize custom fields on creation', () => { it('should initialize custom fields on creation', () => {
expect(component.customFields).toEqual(customFields) expect(component.customFields()).toEqual(customFields)
}) })
it('should add an expression when opened if queries are empty', () => { it('should add an expression when opened if queries are empty', () => {
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String, data_type: CustomFieldDataType.String,
extra_data: {}, extra_data: {},
} }
component.customFields = [field] component.customFields.set([field])
const operators = component.getOperatorsForField(1) const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual( expect(operators.length).toEqual(
[ [
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
], ],
}, },
} }
component.customFields = [field] component.customFields.set([field])
const options = component.getSelectOptionsForField(1) const options = component.getSelectOptionsForField(1)
expect(options).toEqual([ expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' }, { label: 'Option 1', id: 'abc-123' },
@@ -6,6 +6,7 @@ import {
Input, Input,
Output, Output,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -278,7 +279,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output() @Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>() selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
customFields: CustomField[] = [] readonly customFields = signal<CustomField[]>([])
public readonly today: string = new Date().toLocaleDateString('en-CA') public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -325,12 +326,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll() .listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.customFields = result.results this.customFields.set(result.results)
}) })
} }
public getCustomFieldByID(id: number): CustomField { public getCustomFieldByID(id: number): CustomField {
return this.customFields.find((field) => field.id === id) return this.customFields().find((field) => field.id === id)
} }
public addAtom(expression: CustomFieldQueryExpression) { public addAtom(expression: CustomFieldQueryExpression) {
@@ -353,7 +354,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField( getOperatorsForField(
fieldID: number fieldID: number
): Array<{ value: string; label: string }> { ): Array<{ value: string; label: string }> {
const field = this.customFields.find((field) => field.id === fieldID) const field = this.customFields().find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type] ? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic] : [CustomFieldQueryOperatorGroups.Basic]
@@ -369,7 +370,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField( getSelectOptionsForField(
fieldID: number fieldID: number
): Array<{ label: string; id: string }> { ): Array<{ label: string; id: string }> {
const field = this.customFields.find((field) => field.id === fieldID) const field = this.customFields().find((field) => field.id === fieldID)
if (field) { if (field) {
return field.extra_data['select_options'] return field.extra_data['select_options']
} }
@@ -22,11 +22,11 @@
<ng-template> <ng-template>
<div class="card mb-2"> <div class="card mb-2">
<div class="card-body p-2"> <div class="card-body p-2">
@if (testLoading) { @if (testLoading()) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container> <ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult) { } @else if (testResult()) {
<code>{{testResult}}</code> <code>{{testResult()}}</code>
} @else if (testFailed) { } @else if (testFailed()) {
<div class="text-danger" i18n>Path test failed</div> <div class="text-danger" i18n>Path test failed</div>
} @else { } @else {
<div class="text-muted small" i18n>No document selected</div> <div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading" [loading]="loading()"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(change)="testPath($event)"> (change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp> <ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1) expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult).toBe('test/abc123') expect(component.testResult()).toBe('test/abc123')
expect(component.testFailed).toBeFalsy() expect(component.testFailed()).toBeFalsy()
// test failed // test failed
testSpy.mockReturnValueOnce(of('')) testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(component.testResult).toBeNull() expect(component.testResult()).toBeNull()
expect(component.testFailed).toBeTruthy() expect(component.testFailed()).toBeTruthy()
component.testPath(null) component.testPath(null)
expect(component.testResult).toBeNull() expect(component.testResult()).toBeNull()
}) })
it('should compare two documents by id', () => { it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common' import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject } from '@angular/core' import { Component, OnDestroy, inject, signal } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>() public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]> public foundDocuments$: Observable<Document[]>
private testDocument: Document private testDocument: Document
public testResult: string readonly testResult = signal<string>(undefined)
public testFailed: boolean = false readonly testFailed = signal(false)
public testLoading = false readonly testLoading = signal(false)
constructor() { constructor() {
super() super()
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) { public testPath(document: Document) {
if (!document) { if (!document) {
this.testResult = null this.testResult.set(null)
return return
} }
this.testDocument = document this.testDocument = document
this.testLoading = true this.testLoading.set(true)
;(this.service as StoragePathService) ;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id) .testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => { .subscribe((result) => {
if (result?.length) { if (result?.length) {
this.testResult = result this.testResult.set(result)
this.testFailed = false this.testFailed.set(false)
} else { } else {
this.testResult = null this.testResult.set(null)
this.testFailed = true this.testFailed.set(true)
} }
this.testLoading = false this.testLoading.set(false)
}) })
} }
@@ -12,7 +12,7 @@
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color> <pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select> <pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check> <pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core' import { Component, inject, signal } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
], ],
}) })
export class TagEditDialogComponent extends EditDialogComponent<Tag> { export class TagEditDialogComponent extends EditDialogComponent<Tag> {
tags: Tag[] readonly tags = signal<Tag[]>([])
constructor() { constructor() {
super() super()
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
this.userService = inject(UserService) this.userService = inject(UserService)
this.settingsService = inject(SettingsService) this.settingsService = inject(SettingsService)
this.service.listAll().subscribe((result) => { this.service.listAll().subscribe((result) => {
this.tags = result.results this.tags.set(result.results)
}) })
} }
@@ -58,8 +58,8 @@
</button> </button>
} }
@if ((selectionModel.items | filter: filterText:'name').length > 0) { @if ((selectionModel.items | filter: filterText:'name').length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small> <small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button> </button>
} }
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty).toBeTruthy() expect(component.modelIsDirty()).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
const closeSpy = jest.spyOn(component.dropdown, 'close') const closeSpy = jest.spyOn(component.dropdown, 'close')
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
fixture.detectChanges() fixture.detectChanges()
expect(component.modelIsDirty).toBeTruthy() expect(component.modelIsDirty()).toBeTruthy()
let applyResult: ChangedItems let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result)) component.apply.subscribe((result) => (applyResult = result))
component.dropdown.close() component.dropdown.close()
@@ -12,6 +12,7 @@ import {
Output, Output,
ViewChild, ViewChild,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -796,7 +797,7 @@ export class FilterableDropdownComponent
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }
modelIsDirty: boolean = false readonly modelIsDirty = signal(false)
private keyboardIndex: number private keyboardIndex: number
@@ -811,7 +812,7 @@ export class FilterableDropdownComponent
constructor() { constructor() {
super() super()
this.selectionModelChange.subscribe((updatedModel) => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty() this.modelIsDirty.set(updatedModel.isDirty())
}) })
} }
@@ -858,7 +859,7 @@ export class FilterableDropdownComponent
}, 0) }, 0)
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
this.modelIsDirty = false this.modelIsDirty.set(false)
} }
this.selectionModel.singleSelect = this.selectionModel.singleSelect =
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
@@ -5,6 +5,7 @@ import {
inject, inject,
Input, Input,
Output, Output,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -63,11 +64,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
super() super()
customFieldsService.listAll().subscribe((items) => { customFieldsService.listAll().subscribe((items) => {
this.fields = items.results this.fields.set(items.results)
}) })
} }
private fields: CustomField[] private readonly fields = signal<CustomField[]>([])
private _selectedFields: number[] private _selectedFields: number[]
@@ -90,6 +91,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>() public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
public getCustomField(id: number): CustomField { public getCustomField(id: number): CustomField {
return this.fields.find((field) => field.id === id) return this.fields().find((field) => field.id === id)
} }
} }
@@ -33,7 +33,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading" [loading]="loading()"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)"> (change)="onChange(selectedDocumentIDs)">
@@ -2,7 +2,11 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NG_VALUE_ACCESSOR } from '@angular/forms' import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { of, throwError } from 'rxjs' import { By } from '@angular/platform-browser'
import { provideRouter } from '@angular/router'
import { NgSelectComponent } from '@ng-select/ng-select'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of, Subject, throwError } from 'rxjs'
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type' import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component' import { DocumentLinkComponent } from './document-link.component'
@@ -33,10 +37,11 @@ describe('DocumentLinkComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [DocumentLinkComponent], imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
provideRouter([]),
], ],
}) })
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
@@ -60,6 +65,25 @@ describe('DocumentLinkComponent', () => {
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
}) })
it('should render loading and selected documents after async updates', async () => {
const result$ = new Subject<any>()
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
component.writeValue([1])
await fixture.whenStable()
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
.componentInstance as NgSelectComponent
expect(select.loading()).toBe(true)
result$.next({ count: 1, all: [1], results: [documents[0]] })
result$.complete()
await fixture.whenStable()
expect(select.loading()).toBe(false)
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
})
it('shoud maintain ordering of selected documents', () => { it('shoud maintain ordering of selected documents', () => {
const getSpy = jest.spyOn(documentService, 'getFew') const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => { getSpy.mockImplementation((ids) => {
@@ -6,6 +6,7 @@ import {
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormsModule, FormsModule,
@@ -63,7 +64,7 @@ export class DocumentLinkComponent
documentsInput$ = new Subject<string>() documentsInput$ = new Subject<string>()
foundDocuments$: Observable<Document[]> foundDocuments$: Observable<Document[]>
loading = false readonly loading = signal(false)
selectedDocuments: Document[] = [] selectedDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
@@ -93,12 +94,12 @@ export class DocumentLinkComponent
this.selectedDocuments = [] this.selectedDocuments = []
super.writeValue([]) super.writeValue([])
} else { } else {
this.loading = true this.loading.set(true)
this.documentsService this.documentsService
.getFew(documentIDs, { fields: 'id,title' }) .getFew(documentIDs, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documentResults) => { .subscribe((documentResults) => {
this.loading = false this.loading.set(false)
this.selectedDocuments = documentIDs.map( this.selectedDocuments = documentIDs.map(
(id) => documentResults.results.find((d) => d.id === id) ?? {} (id) => documentResults.results.find((d) => d.id === id) ?? {}
) )
@@ -113,7 +114,7 @@ export class DocumentLinkComponent
this.documentsInput$.pipe( this.documentsInput$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
tap(() => (this.loading = true)), tap(() => this.loading.set(true)),
switchMap((title) => switchMap((title) =>
this.documentsService this.documentsService
.listFiltered( .listFiltered(
@@ -133,7 +134,7 @@ export class DocumentLinkComponent
) )
), ),
catchError(() => of([])), // empty on error catchError(() => of([])), // empty on error
tap(() => (this.loading = false)) tap(() => this.loading.set(false))
) )
) )
) )
@@ -37,6 +37,7 @@ export class NumberComponent extends AbstractInputComponent<number> {
this.documentService.getNextAsn().subscribe((nextAsn) => { this.documentService.getNextAsn().subscribe((nextAsn) => {
this.value = nextAsn this.value = nextAsn
this.onChange(this.value) this.onChange(this.value)
this.changeDetector.markForCheck()
}) })
} }
@@ -68,7 +68,7 @@
[(ngModel)]="selectionModel.includeUsers" [(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled" [disabled]="disabled"
[clearable]="false" [clearable]="false"
[items]="users" [items]="users()"
bindLabel="username" bindLabel="username"
multiple="true" multiple="true"
bindValue="id" bindValue="id"
@@ -1,5 +1,12 @@
import { NgClass } from '@angular/common' import { NgClass } from '@angular/common'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core' import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select' import { NgSelectComponent } from '@ng-select/ng-select'
@@ -75,7 +82,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
@Output() @Output()
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>() ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
users: User[] readonly users = signal<User[]>([])
hideUnowned: boolean hideUnowned: boolean
@@ -102,7 +109,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (result) => (this.users = result.results), next: (result) => this.users.set(result.results),
}) })
} }
} }
@@ -57,15 +57,15 @@
</div> </div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@if (socialAccounts?.length > 0) { @if (socialAccounts().length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connected social accounts</p> <p i18n>Connected social accounts</p>
<ul class="list-group"> <ul class="list-group">
@for (account of socialAccounts; track account.id) { @for (account of socialAccounts(); track account.id) {
<li class="list-group-item" <li class="list-group-item"
ngbPopover="Set a password before disconnecting social account." ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover i18n-ngbPopover
[disablePopover]="hasUsablePassword" [disablePopover]="hasUsablePassword()"
triggers="mouseenter:mouseleave"> triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}}) {{account.name}} ({{account.provider}})
<pngx-confirm-button <pngx-confirm-button
@@ -75,7 +75,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline" buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash" iconName="trash"
[disabled]="!hasUsablePassword" [disabled]="!hasUsablePassword()"
(confirm)="disconnectSocialAccount(account.id)"> (confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button> </pngx-confirm-button>
</li> </li>
@@ -84,11 +84,11 @@
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div> <div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div> </div>
} }
@if (socialAccountProviders?.length > 0) { @if (socialAccountProviders().length > 0) {
<div class="mb-3"> <div class="mb-3">
<p i18n>Connect new social account</p> <p i18n>Connect new social account</p>
<div class="list-group"> <div class="list-group">
@for (provider of socialAccountProviders; track provider.name) { @for (provider of socialAccountProviders(); track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer"> <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs> {{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
</a> </a>
@@ -96,7 +96,7 @@
</div> </div>
</div> </div>
} }
@if (!isTotpEnabled) { @if (!isTotpEnabled()) {
<div ngbAccordion> <div ngbAccordion>
<div ngbAccordionItem> <div ngbAccordionItem>
<h2 ngbAccordionHeader> <h2 ngbAccordionHeader>
@@ -105,10 +105,10 @@
<div ngbAccordionCollapse> <div ngbAccordionCollapse>
<div ngbAccordionBody> <div ngbAccordionBody>
<ng-template> <ng-template>
@if (totpSettingsLoading) { @if (totpSettingsLoading()) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings) { } @else if (totpSettings()) {
<figure class="figure"> <figure class="figure">
@if (qrSvgDataUrl) { @if (qrSvgDataUrl) {
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code"> <img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
@@ -116,14 +116,14 @@
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption> <figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure> </figure>
<p> <p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>. <ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container> <ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p> </p>
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder> <input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading"> <button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()">
<ng-container i18n>Enable</ng-container> <ng-container i18n>Enable</ng-container>
@if (totpLoading) { @if (totpLoading()) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} }
@@ -137,18 +137,18 @@
</div> </div>
} @else { } @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label> <label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes) { @if (recoveryCodes()) {
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container> <i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div> </div>
<div class="d-flex flex-row align-items-start mb-3"> <div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50"> <ul class="list-group w-50">
@for (code of recoveryCodes; track code; let i = $index) { @for (code of recoveryCodes(); track code; let i = $index) {
@if (i % 2 === 0) { @if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center"> <li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code> <code>{{code}}</code>
@if (recoveryCodes[i + 1]) { @if (recoveryCodes()[i + 1]) {
<code>{{recoveryCodes[i + 1]}}</code> <code>{{recoveryCodes()[i + 1]}}</code>
} }
</li> </li>
} }
@@ -171,7 +171,7 @@
i18n-title i18n-title
buttonClasses="btn-outline-danger btn-sm" buttonClasses="btn-outline-danger btn-sm"
iconName="trash" iconName="trash"
[disabled]="totpLoading" [disabled]="totpLoading()"
(confirm)="deactivateTotp()"> (confirm)="deactivateTotp()">
</pngx-confirm-button> </pngx-confirm-button>
} }
@@ -10,7 +10,7 @@ import {
NgbPopoverModule, NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { NEVER, of, Subject, throwError } from 'rxjs'
import { ProfileService } from 'src/app/services/profile.service' import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation' import * as navUtils from 'src/app/utils/navigation'
@@ -60,6 +60,10 @@ describe('ProfileEditDialogComponent', () => {
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())], providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
}) })
profileService = TestBed.inject(ProfileService) profileService = TestBed.inject(ProfileService)
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(of([]))
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent) fixture = TestBed.createComponent(ProfileEditDialogComponent)
@@ -155,7 +159,7 @@ describe('ProfileEditDialogComponent', () => {
'getSocialAccountProviders' 'getSocialAccountProviders'
) )
getProvidersSpy.mockReturnValue(of(socialAccountProviders)) getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.hasUsablePassword = true component.hasUsablePassword.set(true)
component.ngOnInit() component.ngOnInit()
component.form.get('password').patchValue('new*pass') component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({ component.onPasswordKeyUp({
@@ -268,6 +272,27 @@ describe('ProfileEditDialogComponent', () => {
expect(getProvidersSpy).toHaveBeenCalled() expect(getProvidersSpy).toHaveBeenCalled()
}) })
it('should render social account providers after an async update', async () => {
const providers$ = new Subject<typeof socialAccountProviders>()
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(providers$)
component.ngOnInit()
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).toBeNull()
providers$.next(socialAccountProviders)
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('a[href="https://example.com"]')
).not.toBeNull()
})
it('should remove disconnected social account from component, show error if needed', () => { it('should remove disconnected social account from component, show error if needed', () => {
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount') const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
const getSpy = jest.spyOn(profileService, 'get') const getSpy = jest.spyOn(profileService, 'get')
@@ -276,7 +301,7 @@ describe('ProfileEditDialogComponent', () => {
const errorSpy = jest.spyOn(toastService, 'showError') const errorSpy = jest.spyOn(toastService, 'showError')
expect(component.socialAccounts).toContainEqual(socialAccount) expect(component.socialAccounts()).toContainEqual(socialAccount)
// fail first // fail first
disconnectSpy.mockReturnValueOnce( disconnectSpy.mockReturnValueOnce(
@@ -289,7 +314,7 @@ describe('ProfileEditDialogComponent', () => {
disconnectSpy.mockReturnValue(of(socialAccount.id)) disconnectSpy.mockReturnValue(of(socialAccount.id))
component.disconnectSocialAccount(socialAccount.id) component.disconnectSocialAccount(socialAccount.id)
expect(disconnectSpy).toHaveBeenCalled() expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts).not.toContainEqual(socialAccount) expect(component.socialAccounts()).not.toContainEqual(socialAccount)
}) })
it('should get totp settings', () => { it('should get totp settings', () => {
@@ -310,7 +335,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockReturnValue(of(settings)) getSpy.mockReturnValue(of(settings))
component.gettotpSettings() component.gettotpSettings()
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings).toEqual(settings) expect(component.totpSettings()).toEqual(settings)
}) })
it('should activate totp', () => { it('should activate totp', () => {
@@ -319,15 +344,15 @@ describe('ProfileEditDialogComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp') const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error)) activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings = { component.totpSettings.set({
url: 'http://localhost/', url: 'http://localhost/',
qr_svg: 'svg', qr_svg: 'svg',
secret: 'secret', secret: 'secret',
} })
component.form.get('totp_code').patchValue('123456') component.form.get('totp_code').patchValue('123456')
component.activateTotp() component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith( expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings.secret, component.totpSettings().secret,
component.form.get('totp_code').value component.form.get('totp_code').value
) )
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
@@ -341,8 +366,8 @@ describe('ProfileEditDialogComponent', () => {
) )
component.activateTotp() component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeTruthy() expect(component.isTotpEnabled()).toBeTruthy()
expect(component.recoveryCodes).toEqual(['1', '2', '3']) expect(component.recoveryCodes()).toEqual(['1', '2', '3'])
}) })
it('should deactivate totp', () => { it('should deactivate totp', () => {
@@ -362,13 +387,13 @@ describe('ProfileEditDialogComponent', () => {
deactivateSpy.mockReturnValueOnce(of(true)) deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp() component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeFalsy() expect(component.isTotpEnabled()).toBeFalsy()
}) })
it('should copy recovery codes', () => { it('should copy recovery codes', () => {
jest.useFakeTimers() jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy') const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes = ['1', '2', '3'] component.recoveryCodes.set(['1', '2', '3'])
component.copyRecoveryCodes() component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3') expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
jest.advanceTimersByTime(3000) jest.advanceTimersByTime(3000)
@@ -56,6 +56,14 @@ export class ProfileEditDialogComponent
readonly showEmailConfirm = signal(false) readonly showEmailConfirm = signal(false)
readonly copied = signal(false) readonly copied = signal(false)
readonly codesCopied = signal(false) readonly codesCopied = signal(false)
readonly hasUsablePassword = signal(false)
readonly isTotpEnabled = signal(false)
readonly totpSettings = signal<TotpSettings>(undefined)
readonly totpSettingsLoading = signal(false)
readonly totpLoading = signal(false)
readonly recoveryCodes = signal<string[]>(undefined)
readonly socialAccounts = signal<SocialAccount[]>([])
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
public form = new FormGroup({ public form = new FormGroup({
email: new FormControl(''), email: new FormControl(''),
@@ -72,25 +80,15 @@ export class ProfileEditDialogComponent
private newPassword: string private newPassword: string
private passwordConfirm: string private passwordConfirm: string
public hasUsablePassword: boolean = false
private currentEmail: string private currentEmail: string
private newEmail: string private newEmail: string
private emailConfirm: string private emailConfirm: string
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
get qrSvgDataUrl(): string | null { get qrSvgDataUrl(): string | null {
if (!this.totpSettings?.qr_svg) { if (!this.totpSettings()?.qr_svg) {
return null return null
} }
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}` return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}`
} }
ngOnInit(): void { ngOnInit(): void {
@@ -107,20 +105,20 @@ export class ProfileEditDialogComponent
this.onEmailChange() this.onEmailChange()
}) })
this.currentPassword = profile.password this.currentPassword = profile.password
this.hasUsablePassword = profile.has_usable_password this.hasUsablePassword.set(profile.has_usable_password)
this.form.get('password').valueChanges.subscribe((newPassword) => { this.form.get('password').valueChanges.subscribe((newPassword) => {
this.newPassword = newPassword this.newPassword = newPassword
this.onPasswordChange() this.onPasswordChange()
}) })
this.socialAccounts = profile.social_accounts this.socialAccounts.set(profile.social_accounts ?? [])
this.isTotpEnabled = profile.is_mfa_enabled this.isTotpEnabled.set(profile.is_mfa_enabled)
}) })
this.profileService this.profileService
.getSocialAccountProviders() .getSocialAccountProviders()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((providers) => { .subscribe((providers) => {
this.socialAccountProviders = providers this.socialAccountProviders.set(providers ?? [])
}) })
} }
@@ -259,7 +257,9 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (id: number) => { next: (id: number) => {
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id) this.socialAccounts.update((accounts) =>
accounts.filter((account) => account.id != id)
)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
@@ -271,36 +271,39 @@ export class ProfileEditDialogComponent
} }
public gettotpSettings(): void { public gettotpSettings(): void {
this.totpSettingsLoading = true this.totpSettingsLoading.set(true)
this.profileService this.profileService
.getTotpSettings() .getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (totpSettings) => { next: (totpSettings) => {
this.totpSettingsLoading = false this.totpSettingsLoading.set(false)
this.totpSettings = totpSettings this.totpSettings.set(totpSettings)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
$localize`Error fetching TOTP settings`, $localize`Error fetching TOTP settings`,
error error
) )
this.totpSettingsLoading = false this.totpSettingsLoading.set(false)
}, },
}) })
} }
public activateTotp(): void { public activateTotp(): void {
this.totpLoading = true this.totpLoading.set(true)
this.form.get('totp_code').disable() this.form.get('totp_code').disable()
this.profileService this.profileService
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value) .activateTotp(
this.totpSettings().secret,
this.form.get('totp_code').value
)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (activationResponse) => { next: (activationResponse) => {
this.totpLoading = false this.totpLoading.set(false)
this.isTotpEnabled = activationResponse.success this.isTotpEnabled.set(activationResponse.success)
this.recoveryCodes = activationResponse.recovery_codes this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
if (activationResponse.success) { if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`) this.toastService.showInfo($localize`TOTP activated successfully`)
@@ -309,7 +312,7 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading = false this.totpLoading.set(false)
this.form.get('totp_code').enable() this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error) this.toastService.showError($localize`Error activating TOTP`, error)
}, },
@@ -317,15 +320,15 @@ export class ProfileEditDialogComponent
} }
public deactivateTotp(): void { public deactivateTotp(): void {
this.totpLoading = true this.totpLoading.set(true)
this.profileService this.profileService
.deactivateTotp() .deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (success) => { next: (success) => {
this.totpLoading = false this.totpLoading.set(false)
this.isTotpEnabled = !success this.isTotpEnabled.set(!success)
this.recoveryCodes = null this.recoveryCodes.set(null)
if (success) { if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`) this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else { } else {
@@ -333,14 +336,14 @@ export class ProfileEditDialogComponent
} }
}, },
error: (error) => { error: (error) => {
this.totpLoading = false this.totpLoading.set(false)
this.toastService.showError($localize`Error deactivating TOTP`, error) this.toastService.showError($localize`Error deactivating TOTP`, error)
}, },
}) })
} }
public copyRecoveryCodes(): void { public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes.join('\n')) this.clipboard.copy(this.recoveryCodes().join('\n'))
this.codesCopied.set(true) this.codesCopied.set(true)
setTimeout(() => { setTimeout(() => {
this.codesCopied.set(false) this.codesCopied.set(false)
@@ -14,7 +14,9 @@
} }
<div content class="wrapper fade" [class.show]="show()"> <div content class="wrapper fade" [class.show]="show()">
@if (displayMode() === DisplayMode.TABLE) { @if (error()) {
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
} @else if (displayMode() === DisplayMode.TABLE) {
<table class="table table-hover mb-0 mt-n2 align-middle"> <table class="table table-hover mb-0 mt-n2 align-middle">
<thead> <thead>
<tr> <tr>
@@ -1,6 +1,10 @@
import { DragDropModule } from '@angular/cdk/drag-drop' import { DragDropModule } from '@angular/cdk/drag-drop'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import {
HttpErrorResponse,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
@@ -8,7 +12,7 @@ import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of } from 'rxjs' import { Subject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component' import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
@@ -230,6 +234,27 @@ describe('SavedViewWidgetComponent', () => {
expect(component.documents()).toEqual(documentResults) expect(component.documents()).toEqual(documentResults)
}) })
it('should show an error if documents fail to load', () => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
error: { added__date__lte: ['Enter a valid date.'] },
status: 400,
})
)
)
component.reload()
fixture.detectChanges()
expect(component.loading()).toBe(false)
expect(component.error()).toEqual('Added: Enter a valid date.')
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Error while loading documents: Added: Enter a valid date.'
)
})
it('should reload on document consumption finished', () => { it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
@@ -125,6 +125,8 @@ export class SavedViewWidgetComponent
readonly count = signal<number>(null) readonly count = signal<number>(null)
readonly error = signal<string | null>(null)
placeholderRows: number[] = [] placeholderRows: number[] = []
ngOnInit(): void { ngOnInit(): void {
@@ -180,6 +182,7 @@ export class SavedViewWidgetComponent
reload() { reload() {
this.loading.set(this.documents().length == 0) this.loading.set(this.documents().length == 0)
this.error.set(null)
this.show.set(true) this.show.set(true)
this.documentService this.documentService
.listFiltered( .listFiltered(
@@ -191,12 +194,40 @@ export class SavedViewWidgetComponent
{ truncate_content: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe({
this.documents.set(result.results) next: (result) => {
this.count.set(result.count) this.documents.set(result.results)
this.savedViewService.setDocumentCount(this.savedView, result.count) this.count.set(result.count)
this.loading.set(false) this.savedViewService.setDocumentCount(this.savedView, result.count)
this.show.set(true) this.loading.set(false)
this.show.set(true)
},
error: (error) => {
this.documents.set([])
this.count.set(null)
let errorMessage
if (
typeof error.error === 'object' &&
Object.keys(error.error).length > 0
) {
errorMessage = Object.keys(error.error)
.map((fieldName) => {
const fieldNameBase = fieldName.split('__')[0]
const fieldError: Array<string> = error.error[fieldName]
return `${
this.documentService.sortFields.find(
(f) => f.field?.split('__')[0] == fieldNameBase
)?.name ?? fieldNameBase
}: ${fieldError[0]}`
})
.join(', ')
} else {
errorMessage = error.error
}
this.error.set(errorMessage)
this.loading.set(false)
this.show.set(true)
},
}) })
} }
@@ -1 +1 @@
<p i18n>Searching document with asn {{asn}}</p> <p i18n>Searching document with asn {{asn()}}</p>
@@ -1,4 +1,4 @@
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject, signal } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { FILTER_ASN } from '../../data/filter-rule-type' import { FILTER_ASN } from '../../data/filter-rule-type'
import { DocumentService } from '../../services/rest/document.service' import { DocumentService } from '../../services/rest/document.service'
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)
private router = inject(Router) private router = inject(Router)
asn: string readonly asn = signal<string>(undefined)
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe((paramMap) => { this.route.paramMap.subscribe((paramMap) => {
this.asn = paramMap.get('id') this.asn.set(paramMap.get('id'))
this.documentsService this.documentsService
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }]) .listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }])
.subscribe((documentId) => { .subscribe((documentId) => {
if (documentId.length == 1) { if (documentId.length == 1) {
this.router.navigate(['documents', documentId[0]]) this.router.navigate(['documents', documentId[0]])
@@ -482,9 +482,7 @@
</pngx-pdf-viewer> </pngx-pdf-viewer>
</div> </div>
} @else { } @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
<span>Preview is unavailable.</span>
</object>
} }
} }
@case (ContentRenderType.Text) { @case (ContentRenderType.Text) {
@@ -505,9 +503,7 @@
} }
} }
@case (ContentRenderType.Other) { @case (ContentRenderType.Other) {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"> <object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
<span>Preview is unavailable.</span>
</object>
} }
} }
@if (requiresPassword) { @if (requiresPassword) {
@@ -5,7 +5,7 @@
</button> </button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2 mb-2"> <div class="px-3 py-2 mb-2">
@if (versionUploadState === UploadState.Idle) { @if (versionUploadState() === UploadState.Idle) {
<div class="input-group input-group-sm mb-2"> <div class="input-group input-group-sm mb-2">
<label class="input-group-text" for="newVersionLabel" i18n>Label</label> <label class="input-group-text" for="newVersionLabel" i18n>Label</label>
<input <input
@@ -32,7 +32,7 @@
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span> <i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button> </button>
} @else { } @else {
@switch (versionUploadState) { @switch (versionUploadState()) {
@case (UploadState.Uploading) { @case (UploadState.Uploading) {
<div class="small text-muted mt-1 d-flex align-items-center"> <div class="small text-muted mt-1 d-flex align-items-center">
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output> <output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
@@ -50,8 +50,8 @@
<span i18n>Version upload failed.</span> <span i18n>Version upload failed.</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button> <button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
</div> </div>
@if (versionUploadError) { @if (versionUploadError()) {
<div class="small text-muted mt-1">{{ versionUploadError }}</div> <div class="small text-muted mt-1">{{ versionUploadError() }}</div>
} }
} }
} }
@@ -83,7 +83,7 @@
[(ngModel)]="versionLabelDraft" [(ngModel)]="versionLabelDraft"
i18n-placeholder i18n-placeholder
placeholder="Version label" placeholder="Version label"
[disabled]="savingVersionLabelId !== null" [disabled]="savingVersionLabelId() !== null"
(keydown.enter)="submitEditedVersionLabel(version, $event)" (keydown.enter)="submitEditedVersionLabel(version, $event)"
(keydown.escape)="cancelEditingVersion($event)" (keydown.escape)="cancelEditingVersion($event)"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
@@ -101,7 +101,7 @@
<button <button
type="button" type="button"
class="btn btn-outline-secondary" class="btn btn-outline-secondary"
[disabled]="savingVersionLabelId !== null" [disabled]="savingVersionLabelId() !== null"
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)" (click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
> >
@if (isEditingVersion(version.id)) { @if (isEditingVersion(version.id)) {
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
{ id: 3, is_root: true, checksum: 'aaaa' }, { id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' }, { id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
]) ])
expect(component.savingVersionLabelId).toBeNull() expect(component.savingVersionLabelId()).toBeNull()
}) })
it('saveVersionLabel should show error toast on failure', () => { it('saveVersionLabel should show error toast on failure', () => {
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
'Error updating version label', 'Error updating version label',
error error
) )
expect(component.savingVersionLabelId).toBeNull() expect(component.savingVersionLabelId()).toBeNull()
}) })
it('onVersionFileSelected should upload and update versions after websocket success', () => { it('onVersionFileSelected should upload and update versions after websocket success', () => {
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
expect(versionsEmitSpy).toHaveBeenCalledWith(versions) expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(20) expect(selectedEmitSpy).toHaveBeenCalledWith(20)
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(component.versionUploadState).toEqual(UploadState.Idle) expect(component.versionUploadState()).toEqual(UploadState.Idle)
expect(component.versionUploadError).toBeNull() expect(component.versionUploadError()).toBeNull()
}) })
it('onVersionFileSelected should set failed state after websocket failure', () => { it('onVersionFileSelected should render failed state after websocket failure', async () => {
const file = new File(['test'], 'new-version.pdf', { const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf', type: 'application/pdf',
}) })
@@ -266,9 +266,11 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
failed$.next({ taskId: 'task-1', message: 'processing failed' }) failed$.next({ taskId: 'task-1', message: 'processing failed' })
await fixture.whenStable()
expect(component.versionUploadState).toEqual(UploadState.Failed) expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('processing failed') expect(component.versionUploadError()).toEqual('processing failed')
expect(fixture.nativeElement.textContent).toContain('processing failed')
expect(documentService.getVersions).not.toHaveBeenCalled() expect(documentService.getVersions).not.toHaveBeenCalled()
}) })
@@ -282,8 +284,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState).toEqual(UploadState.Failed) expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('Missing task ID.') expect(component.versionUploadError()).toEqual('Missing task ID.')
expect(documentService.getVersions).not.toHaveBeenCalled() expect(documentService.getVersions).not.toHaveBeenCalled()
}) })
@@ -298,8 +300,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event) component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState).toEqual(UploadState.Failed) expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('upload failed') expect(component.versionUploadError()).toEqual('upload failed')
expect(toastService.showError).toHaveBeenCalledWith( expect(toastService.showError).toHaveBeenCalledWith(
'Error uploading new version', 'Error uploading new version',
error error
@@ -307,8 +309,8 @@ describe('DocumentVersionDropdownComponent', () => {
}) })
it('ngOnChanges should clear upload status on document switch', () => { it('ngOnChanges should clear upload status on document switch', () => {
component.versionUploadState = UploadState.Failed component.versionUploadState.set(UploadState.Failed)
component.versionUploadError = 'something failed' component.versionUploadError.set('something failed')
component.editingVersionId = 10 component.editingVersionId = 10
component.versionLabelDraft = 'draft' component.versionLabelDraft = 'draft'
@@ -316,8 +318,8 @@ describe('DocumentVersionDropdownComponent', () => {
documentId: new SimpleChange(3, 4, false), documentId: new SimpleChange(3, 4, false),
}) })
expect(component.versionUploadState).toEqual(UploadState.Idle) expect(component.versionUploadState()).toEqual(UploadState.Idle)
expect(component.versionUploadError).toBeNull() expect(component.versionUploadError()).toBeNull()
expect(component.editingVersionId).toBeNull() expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('') expect(component.versionLabelDraft).toEqual('')
}) })
@@ -7,6 +7,7 @@ import {
OnChanges, OnChanges,
OnDestroy, OnDestroy,
Output, Output,
signal,
SimpleChanges, SimpleChanges,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
@@ -59,9 +60,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>() @Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
newVersionLabel: string = '' newVersionLabel: string = ''
versionUploadState: UploadState = UploadState.Idle readonly versionUploadState = signal(UploadState.Idle)
versionUploadError: string | null = null readonly versionUploadError = signal<string | null>(null)
savingVersionLabelId: number | null = null readonly savingVersionLabelId = signal<number | null>(null)
editingVersionId: number | null = null editingVersionId: number | null = null
versionLabelDraft: string = '' versionLabelDraft: string = ''
@@ -101,7 +102,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void { beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
if (!this.canEditLabels || this.savingVersionLabelId !== null) return if (!this.canEditLabels || this.savingVersionLabelId() !== null) return
this.editingVersionId = version.id this.editingVersionId = version.id
this.versionLabelDraft = version.version_label ?? '' this.versionLabelDraft = version.version_label ?? ''
} }
@@ -116,7 +117,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void { submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
if (this.savingVersionLabelId !== null) return if (this.savingVersionLabelId() !== null) return
const nextLabel = this.versionLabelDraft?.trim() || null const nextLabel = this.versionLabelDraft?.trim() || null
const currentLabel = version.version_label?.trim() || null const currentLabel = version.version_label?.trim() || null
if (nextLabel === currentLabel) { if (nextLabel === currentLabel) {
@@ -158,15 +159,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
} }
saveVersionLabel(versionId: number, versionLabel: string | null): void { saveVersionLabel(versionId: number, versionLabel: string | null): void {
if (this.savingVersionLabelId !== null) return if (this.savingVersionLabelId() !== null) return
this.savingVersionLabelId = versionId this.savingVersionLabelId.set(versionId)
this.documentsService this.documentsService
.updateVersionLabel(this.documentId, versionId, versionLabel) .updateVersionLabel(this.documentId, versionId, versionLabel)
.pipe( .pipe(
first(), first(),
finalize(() => { finalize(() => {
if (this.savingVersionLabelId === versionId) { if (this.savingVersionLabelId() === versionId) {
this.savingVersionLabelId = null this.savingVersionLabelId.set(null)
} }
}), }),
takeUntil(this.destroy$) takeUntil(this.destroy$)
@@ -199,8 +200,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
const file = input.files[0] const file = input.files[0]
input.value = '' input.value = ''
const label = this.newVersionLabel?.trim() const label = this.newVersionLabel?.trim()
this.versionUploadState = UploadState.Uploading this.versionUploadState.set(UploadState.Uploading)
this.versionUploadError = null this.versionUploadError.set(null)
this.documentsService this.documentsService
.uploadVersion(uploadDocumentId, file, label) .uploadVersion(uploadDocumentId, file, label)
.pipe( .pipe(
@@ -210,7 +211,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
$localize`Uploading new version. Processing will happen in the background.` $localize`Uploading new version. Processing will happen in the background.`
) )
this.newVersionLabel = '' this.newVersionLabel = ''
this.versionUploadState = UploadState.Processing this.versionUploadState.set(UploadState.Processing)
}), }),
map((taskId) => map((taskId) =>
typeof taskId === 'string' typeof taskId === 'string'
@@ -219,8 +220,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
), ),
switchMap((taskId) => { switchMap((taskId) => {
if (!taskId) { if (!taskId) {
this.versionUploadState = UploadState.Failed this.versionUploadState.set(UploadState.Failed)
this.versionUploadError = $localize`Missing task ID.` this.versionUploadError.set($localize`Missing task ID.`)
return of(null) return of(null)
} }
return merge( return merge(
@@ -240,9 +241,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
switchMap((result) => { switchMap((result) => {
if (result?.state !== 'success') { if (result?.state !== 'success') {
if (result?.state === 'failed') { if (result?.state === 'failed') {
this.versionUploadState = UploadState.Failed this.versionUploadState.set(UploadState.Failed)
this.versionUploadError = this.versionUploadError.set(
result.message || $localize`Upload failed.` result.message || $localize`Upload failed.`
)
} }
return of(null) return of(null)
} }
@@ -264,8 +266,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}, },
error: (error) => { error: (error) => {
if (uploadDocumentId !== this.documentId) return if (uploadDocumentId !== this.documentId) return
this.versionUploadState = UploadState.Failed this.versionUploadState.set(UploadState.Failed)
this.versionUploadError = error?.message || $localize`Upload failed.` this.versionUploadError.set(
error?.message || $localize`Upload failed.`
)
this.toastService.showError( this.toastService.showError(
$localize`Error uploading new version`, $localize`Error uploading new version`,
error error
@@ -275,7 +279,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
} }
clearVersionUploadStatus(): void { clearVersionUploadStatus(): void {
this.versionUploadState = UploadState.Idle this.versionUploadState.set(UploadState.Idle)
this.versionUploadError = null this.versionUploadError.set(null)
} }
} }
@@ -10,7 +10,7 @@
[createRef]="createTag.bind(this)" [createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()" (opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts" [documentCounts]="tagDocumentCounts()"
(apply)="setTags($event)" (apply)="setTags($event)"
shortcutKey="t"> shortcutKey="t">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -24,7 +24,7 @@
[createRef]="createCorrespondent.bind(this)" [createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()" (opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel" [(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts" [documentCounts]="correspondentDocumentCounts()"
(apply)="setCorrespondents($event)" (apply)="setCorrespondents($event)"
shortcutKey="y"> shortcutKey="y">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -38,7 +38,7 @@
[createRef]="createDocumentType.bind(this)" [createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()" (opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel" [(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts" [documentCounts]="documentTypeDocumentCounts()"
(apply)="setDocumentTypes($event)" (apply)="setDocumentTypes($event)"
shortcutKey="u"> shortcutKey="u">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -52,7 +52,7 @@
[createRef]="createStoragePath.bind(this)" [createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()" (opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel" [(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts" [documentCounts]="storagePathDocumentCounts()"
(apply)="setStoragePaths($event)" (apply)="setStoragePaths($event)"
shortcutKey="i"> shortcutKey="i">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
@@ -66,7 +66,7 @@
[createRef]="createCustomField.bind(this)" [createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()" (opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel" [(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts" [documentCounts]="customFieldDocumentCounts()"
extraButtonTitle="Set values" extraButtonTitle="Set values"
i18n-extraButtonTitle i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)" (extraButton)="setCustomFieldValues($event)"
@@ -124,11 +124,11 @@
</div> </div>
</div> </div>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
@if (!awaitingDownload) { @if (!awaitingDownload()) {
<i-bs name="arrow-down"></i-bs> <i-bs name="arrow-down"></i-bs>
} }
@if (awaitingDownload) { @if (awaitingDownload()) {
<div class="spinner-border spinner-border-sm" role="status"> <div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span> <span class="visually-hidden">Preparing download...</span>
</div> </div>
@@ -303,7 +303,7 @@ describe('BulkEditorComponent', () => {
component.openDocumentTypeDropdown() component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts).toEqual( expect(component.documentTypeDocumentCounts()).toEqual(
selectionData.selected_document_types selectionData.selected_document_types
) )
}) })
@@ -320,7 +320,7 @@ describe('BulkEditorComponent', () => {
component.openCorrespondentDropdown() component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts).toEqual( expect(component.correspondentDocumentCounts()).toEqual(
selectionData.selected_correspondents selectionData.selected_correspondents
) )
}) })
@@ -337,7 +337,7 @@ describe('BulkEditorComponent', () => {
component.openStoragePathDropdown() component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts).toEqual( expect(component.storagePathDocumentCounts()).toEqual(
selectionData.selected_storage_paths selectionData.selected_storage_paths
) )
}) })
@@ -354,7 +354,7 @@ describe('BulkEditorComponent', () => {
component.openCustomFieldsDropdown() component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled() expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts).toEqual( expect(component.customFieldDocumentCounts()).toEqual(
selectionData.selected_custom_fields selectionData.selected_custom_fields
) )
}) })
@@ -1,4 +1,11 @@
import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core' import {
Component,
inject,
Input,
OnDestroy,
OnInit,
signal,
} from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -101,12 +108,12 @@ export class BulkEditorComponent
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true) customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
tagDocumentCounts: SelectionDataItem[] readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
correspondentDocumentCounts: SelectionDataItem[] readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
documentTypeDocumentCounts: SelectionDataItem[] readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
storagePathDocumentCounts: SelectionDataItem[] readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
customFieldDocumentCounts: SelectionDataItem[] readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
awaitingDownload: boolean readonly awaitingDownload = signal(false)
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@@ -365,8 +372,8 @@ export class BulkEditorComponent
openTagsDropdown() { openTagsDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.tagDocumentCounts = selectionData?.selected_tags ?? [] this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel) this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
return return
} }
@@ -374,7 +381,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.tagDocumentCounts = s.selected_tags this.tagDocumentCounts.set(s.selected_tags)
this.applySelectionData(s.selected_tags, this.tagSelectionModel) this.applySelectionData(s.selected_tags, this.tagSelectionModel)
}) })
} }
@@ -382,10 +389,11 @@ export class BulkEditorComponent
openDocumentTypeDropdown() { openDocumentTypeDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.documentTypeDocumentCounts = this.documentTypeDocumentCounts.set(
selectionData?.selected_document_types ?? [] selectionData?.selected_document_types ?? []
)
this.applySelectionData( this.applySelectionData(
this.documentTypeDocumentCounts, this.documentTypeDocumentCounts(),
this.documentTypeSelectionModel this.documentTypeSelectionModel
) )
return return
@@ -395,7 +403,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.documentTypeDocumentCounts = s.selected_document_types this.documentTypeDocumentCounts.set(s.selected_document_types)
this.applySelectionData( this.applySelectionData(
s.selected_document_types, s.selected_document_types,
this.documentTypeSelectionModel this.documentTypeSelectionModel
@@ -406,10 +414,11 @@ export class BulkEditorComponent
openCorrespondentDropdown() { openCorrespondentDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.correspondentDocumentCounts = this.correspondentDocumentCounts.set(
selectionData?.selected_correspondents ?? [] selectionData?.selected_correspondents ?? []
)
this.applySelectionData( this.applySelectionData(
this.correspondentDocumentCounts, this.correspondentDocumentCounts(),
this.correspondentSelectionModel this.correspondentSelectionModel
) )
return return
@@ -419,7 +428,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.correspondentDocumentCounts = s.selected_correspondents this.correspondentDocumentCounts.set(s.selected_correspondents)
this.applySelectionData( this.applySelectionData(
s.selected_correspondents, s.selected_correspondents,
this.correspondentSelectionModel this.correspondentSelectionModel
@@ -430,10 +439,11 @@ export class BulkEditorComponent
openStoragePathDropdown() { openStoragePathDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.storagePathDocumentCounts = this.storagePathDocumentCounts.set(
selectionData?.selected_storage_paths ?? [] selectionData?.selected_storage_paths ?? []
)
this.applySelectionData( this.applySelectionData(
this.storagePathDocumentCounts, this.storagePathDocumentCounts(),
this.storagePathsSelectionModel this.storagePathsSelectionModel
) )
return return
@@ -443,7 +453,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.storagePathDocumentCounts = s.selected_storage_paths this.storagePathDocumentCounts.set(s.selected_storage_paths)
this.applySelectionData( this.applySelectionData(
s.selected_storage_paths, s.selected_storage_paths,
this.storagePathsSelectionModel this.storagePathsSelectionModel
@@ -454,10 +464,11 @@ export class BulkEditorComponent
openCustomFieldsDropdown() { openCustomFieldsDropdown() {
if (this.list.allSelected) { if (this.list.allSelected) {
const selectionData = this.list.selectionData const selectionData = this.list.selectionData
this.customFieldDocumentCounts = this.customFieldDocumentCounts.set(
selectionData?.selected_custom_fields ?? [] selectionData?.selected_custom_fields ?? []
)
this.applySelectionData( this.applySelectionData(
this.customFieldDocumentCounts, this.customFieldDocumentCounts(),
this.customFieldsSelectionModel this.customFieldsSelectionModel
) )
return return
@@ -467,7 +478,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first()) .pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.customFieldDocumentCounts = s.selected_custom_fields this.customFieldDocumentCounts.set(s.selected_custom_fields)
this.applySelectionData( this.applySelectionData(
s.selected_custom_fields, s.selected_custom_fields,
this.customFieldsSelectionModel this.customFieldsSelectionModel
@@ -876,7 +887,7 @@ export class BulkEditorComponent
} }
downloadSelected() { downloadSelected() {
this.awaitingDownload = true this.awaitingDownload.set(true)
let downloadFileType: string = let downloadFileType: string =
this.downloadForm.get('downloadFileTypeArchive').value && this.downloadForm.get('downloadFileTypeArchive').value &&
this.downloadForm.get('downloadFileTypeOriginals').value this.downloadForm.get('downloadFileTypeOriginals').value
@@ -893,7 +904,7 @@ export class BulkEditorComponent
.pipe(first()) .pipe(first())
.subscribe((result: any) => { .subscribe((result: any) => {
saveAs(result, 'documents.zip') saveAs(result, 'documents.zip')
this.awaitingDownload = false this.awaitingDownload.set(false)
}) })
} }
@@ -2,7 +2,7 @@
<div class="row g-0"> <div class="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()"> <div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
@if (document()) { @if (document()) {
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()"> <img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
<div class="border-end border-bottom bg-light document-card-check"> <div class="border-end border-bottom bg-light document-card-check">
<div class="form-check"> <div class="form-check">
@@ -88,6 +88,12 @@ describe('DocumentCardLargeComponent', () => {
expect(fixture.nativeElement.textContent).toContain('8 pages') expect(fixture.nativeElement.textContent).toContain('8 pages')
}) })
it('should lazy load the thumbnail', () => {
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should trim content', () => { it('should trim content', () => {
expect(component.contentTrimmed).toHaveLength(503) // includes ... expect(component.contentTrimmed).toHaveLength(503) // includes ...
}) })
@@ -1,4 +1,4 @@
import { AsyncPipe } from '@angular/common' import { AsyncPipe, NgOptimizedImage } from '@angular/common'
import { import {
AfterViewInit, AfterViewInit,
Component, Component,
@@ -46,6 +46,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
TagComponent, TagComponent,
CustomFieldDisplayComponent, CustomFieldDisplayComponent,
AsyncPipe, AsyncPipe,
NgOptimizedImage,
UsernamePipe, UsernamePipe,
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
@@ -2,7 +2,7 @@
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()"> <div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)"> <div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
@if (document()) { @if (document()) {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()"> <img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill>
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check"> <div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
<div class="form-check"> <div class="form-check">
@@ -22,10 +22,14 @@
} }
} }
.doc-img-container {
position: relative;
height: 180px;
}
.doc-img { .doc-img {
object-fit: cover; object-fit: cover;
object-position: top left; object-position: top left;
height: 180px;
} }
.document-card-check { .document-card-check {
@@ -61,6 +61,12 @@ describe('DocumentCardSmallComponent', () => {
expect(fixture.nativeElement.textContent).toContain('12 pages') expect(fixture.nativeElement.textContent).toContain('12 pages')
}) })
it('should lazy load the thumbnail', () => {
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should display a document, limit tags to 5', () => { it('should display a document, limit tags to 5', () => {
expect(fixture.nativeElement.textContent).toContain('Document 10') expect(fixture.nativeElement.textContent).toContain('Document 10')
expect( expect(
@@ -1,4 +1,4 @@
import { AsyncPipe } from '@angular/common' import { AsyncPipe, NgOptimizedImage } from '@angular/common'
import { import {
AfterViewInit, AfterViewInit,
Component, Component,
@@ -46,6 +46,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
TagComponent, TagComponent,
CustomFieldDisplayComponent, CustomFieldDisplayComponent,
AsyncPipe, AsyncPipe,
NgOptimizedImage,
UsernamePipe, UsernamePipe,
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
@@ -115,7 +115,7 @@
</pngx-page-header> </pngx-page-header>
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body"> <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor> <pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor> <pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
</div> </div>
@@ -5,6 +5,7 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -149,7 +150,7 @@ export class DocumentListComponent
) )
} }
unmodifiedFilterRules: FilterRule[] = [] readonly unmodifiedFilterRules = signal<FilterRule[]>([])
private unmodifiedSavedView: SavedView private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null private activeSavedView: SavedView | null = null
@@ -299,7 +300,7 @@ export class DocumentListComponent
this.savedViewService.setDocumentCount(view, this.list.collectionSize) this.savedViewService.setDocumentCount(view, this.list.collectionSize)
}) })
this.updateDisplayCustomFields() this.updateDisplayCustomFields()
this.unmodifiedFilterRules = view.filter_rules this.unmodifiedFilterRules.set(view.filter_rules)
}) })
this.route.queryParamMap this.route.queryParamMap
@@ -316,7 +317,7 @@ export class DocumentListComponent
this.activeSavedView = null this.activeSavedView = null
this.list.activateSavedView(null) this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams) this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules = [] this.unmodifiedFilterRules.set([])
} }
}) })
@@ -415,7 +416,7 @@ export class DocumentListComponent
this.toastService.showInfo( this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.` $localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
) )
this.unmodifiedFilterRules = this.list.filterRules this.unmodifiedFilterRules.set(this.list.filterRules)
}, },
error: (err) => { error: (err) => {
this.toastService.showError( this.toastService.showError(
@@ -80,7 +80,7 @@
shortcutKey="i"></pngx-filterable-dropdown> shortcutKey="i"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) {
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title <pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
[(selectionModel)]="customFieldQueriesModel" [(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
@@ -1,5 +1,6 @@
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
@@ -9,6 +10,7 @@ import {
Output, Output,
ViewChild, ViewChild,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { import {
@@ -349,7 +351,7 @@ export class FilterEditorComponent
@ViewChild('textFilterInput') @ViewChild('textFilterInput')
textFilterInput: ElementRef textFilterInput: ElementRef
customFields: CustomField[] = [] readonly customFields = signal<CustomField[]>([])
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
@@ -514,6 +516,7 @@ export class FilterEditorComponent
this.documentService.get(this._moreLikeId).subscribe((result) => { this.documentService.get(this._moreLikeId).subscribe((result) => {
this._moreLikeDoc = result this._moreLikeDoc = result
this._textFilter = result.title this._textFilter = result.title
this.changeDetector.markForCheck()
}) })
break break
case FILTER_CREATED_AFTER: case FILTER_CREATED_AFTER:
@@ -1162,6 +1165,7 @@ export class FilterEditorComponent
private loadingCountTotal: number = 0 private loadingCountTotal: number = 0
private loadingCount: number = 0 private loadingCount: number = 0
private readonly changeDetector = inject(ChangeDetectorRef)
private maybeCompleteLoading() { private maybeCompleteLoading() {
this.loadingCount++ this.loadingCount++
@@ -1229,7 +1233,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.customFieldService.listAll().subscribe((result) => { this.customFieldService.listAll().subscribe((result) => {
this.customFields = result.results this.customFields.set(result.results)
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
+43
View File
@@ -177,6 +177,49 @@ class TestViews(DirectoriesMixin, TestCase):
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/") self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
self.assertContains(response, b"Share link has expired") self.assertContains(response, b"Share link has expired")
def test_share_link_archive_falls_back_to_original(self) -> None:
"""
GIVEN:
- A document without an archive version
- A share link using the default archive file version
WHEN:
- An unauthenticated request for the share link is made
THEN:
- The original document is returned
"""
_, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
content = b"This document has no archive"
with Path(filename).open("wb") as f:
f.write(content)
doc = Document.objects.create(
title="no archive",
filename=Path(filename).name,
mime_type="text/plain",
)
sharelink_permissions = Permission.objects.filter(
codename__contains="sharelink",
)
self.user.user_permissions.add(*sharelink_permissions)
self.client.force_login(self.user)
create_response = self.client.post(
"/api/share_links/",
{"document": doc.pk},
)
self.assertEqual(create_response.status_code, status.HTTP_201_CREATED)
share_link = ShareLink.objects.get(document=doc)
self.assertEqual(share_link.file_version, ShareLink.FileVersion.ARCHIVE)
self.client.logout()
response = self.client.get(f"/share/{share_link.slug}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(read_streaming_response(response), content)
def test_list_with_full_permissions(self) -> None: def test_list_with_full_permissions(self) -> None:
""" """
GIVEN: GIVEN:
+2 -1
View File
@@ -4534,7 +4534,8 @@ class SharedLinkView(View):
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
return serve_file( return serve_file(
doc=share_link.document, doc=share_link.document,
use_archive=share_link.file_version == "archive", use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
and share_link.document.has_archive_version,
disposition="inline", disposition="inline",
) )
+4 -4
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n" "POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded." msgid "Maximum nesting depth exceeded."
msgstr "" msgstr ""
#: documents/filters.py:1052 #: documents/filters.py:1059
msgid "Custom field not found" msgid "Custom field not found"
msgstr "" msgstr ""
@@ -1686,11 +1686,11 @@ msgstr ""
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4554 #: documents/views.py:4555
msgid "The share link bundle is still being prepared. Please try again later." msgid "The share link bundle is still being prepared. Please try again later."
msgstr "" msgstr ""
#: documents/views.py:4564 #: documents/views.py:4565
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""