Compare commits

..
Author SHA1 Message Date
Crowdin Bot 2642753e68 New Crowdin translations by GitHub Action 2026-07-20 00:52:41 +00:00
GitHub Actions d34ef75786 Auto translate strings 2026-07-19 22:48:24 +00:00
shamoon b8c424c8e1 Update document-detail.component.html 2026-07-19 15:46:17 -07:00
GitHub Actions ec8991c2ec Auto translate strings 2026-07-19 20:56:43 +00:00
shamoonandGitHub 4f6d9fa93f Fix: dont allow archive version for share links when one doesnt exist (#13163) 2026-07-19 20:54:58 +00:00
GitHub Actions 2180b21c41 Auto translate strings 2026-07-19 14:45:46 +00:00
shamoonandGitHub 99bdfdfe7a Fix: better handle saved view errors in dashboard widgets (#13164) 2026-07-19 07:44:12 -07:00
GitHub Actions 73a0586a75 Auto translate strings 2026-07-18 06:00:02 +00:00
shamoon dcba44ad78 Merge branch 'beta' into dev 2026-07-17 22:57:47 -07:00
shamoon ed54f1a8b9 Fix: more signal-backed conversions 2026-07-17 22:57:37 -07:00
shamoonandGitHub f8a641b402 Fix (beta): convert chat component to signal-backed (#13152) 2026-07-17 22:26:07 -07:00
stumpylog e85223929c Fix: increase checks-discovery-timeout for Wait for Docker Build
The Merge and Push Manifest check isn't materialized until the
build-arch matrix completes (its `if` gates on a job output), which
routinely takes longer than the action's 60s discovery window,
causing the release workflow to bail before Docker builds finish.
2026-07-17 15:27:55 -07:00
145 changed files with 7580 additions and 7166 deletions
+1
View File
@@ -26,6 +26,7 @@ jobs:
check-name: 'Merge and Push Manifest'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60
checks-discovery-timeout: 1800
build-release:
name: Build Release
needs: wait-for-docker
+140 -136
View File
File diff suppressed because it is too large Load Diff
@@ -37,7 +37,7 @@
<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">
<a ngbNavLink i18n>General</a>
<ng-template ngbNavContent>
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
expect(component.activeNavID).toEqual(4) // Notifications
expect(component.activeNavID()).toEqual(4) // Notifications
component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
})
@@ -142,7 +142,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService)
activeNavID: number
readonly activeNavID = signal<number>(undefined)
settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null),
@@ -283,7 +283,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID = SettingsNavIDs[navIDKey]
this.activeNavID.set(SettingsNavIDs[navIDKey])
}
}
})
@@ -386,7 +386,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => {
if (!navigated && this.isDirty) {
this.activeNavID = navChangeEvent.activeId
this.activeNavID.set(navChangeEvent.activeId)
} else if (navigated && this.isDirty) {
this.initialize()
}
@@ -6,7 +6,7 @@
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small">
@for (message of messages; track message) {
@for (message of messages(); track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span>
@@ -36,11 +36,12 @@
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder"
[disabled]="loading"
[(ngModel)]="input"
[disabled]="loading()"
[ngModel]="input()"
(ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)"
/>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
</div>
</form>
</div>
@@ -56,44 +56,53 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit()
expect(component.documentId).toBe(123)
expect(component.documentId()).toBe(123)
})
it('should update documentId on navigation', () => {
component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
expect(component.documentId).toBe(456)
expect(component.documentId()).toBe(456)
})
it('should return correct placeholder based on documentId', () => {
component.documentId = 123
component.documentId.set(123)
expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId = undefined
component.documentId.set(undefined)
expect(component.placeholder).toBe('Ask a question about a document...')
})
it('should send a message and handle streaming response', () => {
component.input = 'Hello'
it('should send a message and render the streaming response', async () => {
component.input.set('Hello')
component.sendMessage()
expect(component.messages).toHaveLength(2)
expect(component.messages[0].content).toBe('Hello')
expect(component.loading).toBe(true)
expect(component.messages()).toHaveLength(2)
expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true)
mockStream$.next('Hi')
expect(component.messages[1].content).toBe('H')
expect(component.messages()[1].content).toBe('H')
mockStream$.next('Hi there')
// advance time to process the typewriter effect
jest.advanceTimersByTime(1000)
expect(component.messages[1].content).toBe('Hi there')
await jest.runAllTimersAsync()
await fixture.whenStable()
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
mockStream$.complete()
expect(component.loading).toBe(false)
expect(component.messages[1].isStreaming).toBe(false)
await jest.runAllTimersAsync()
await fixture.whenStable()
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
})
it('should parse references from the metadata trailer without showing it', () => {
component.input = 'Hello'
component.input.set('Hello')
component.sendMessage()
mockStream$.next(
@@ -101,14 +110,14 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
expect(component.messages[1].content).toBe('Hi there')
expect(component.messages[1].references).toEqual([
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' },
])
})
it('should render document reference links under assistant messages', () => {
component.input = 'Hello'
component.input.set('Hello')
component.sendMessage()
mockStream$.next(
@@ -123,12 +132,12 @@ describe('ChatComponent', () => {
})
it('should remove delimiter fragments that were already streamed', () => {
component.input = 'Hello'
component.input.set('Hello')
component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000)
expect(component.messages[1].content).toBe(
expect(component.messages()[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
)
@@ -137,21 +146,21 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
expect(component.messages[1].content).toBe('Hi there')
expect(component.messages[1].references).toEqual([
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' },
])
})
it('should handle errors during streaming', () => {
component.input = 'Hello'
component.input.set('Hello')
component.sendMessage()
mockStream$.error('Error')
expect(component.messages[1].content).toContain(
expect(component.messages()[1].content).toContain(
'⚠️ Error receiving response.'
)
expect(component.loading).toBe(false)
expect(component.loading()).toBe(false)
})
it('should enqueue typewriter chunks correctly', () => {
@@ -166,7 +175,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any,
'scrollToBottom'
)
component.input = 'Test'
component.input.set('Test')
component.sendMessage()
expect(scrollSpy).toHaveBeenCalled()
})
@@ -1,4 +1,11 @@
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
import {
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -23,10 +30,10 @@ import {
styleUrl: './chat.component.scss',
})
export class ChatComponent implements OnInit {
public messages: ChatMessage[] = []
public loading = false
public input: string = ''
public documentId!: number
readonly messages = signal<ChatMessage[]>([])
readonly loading = signal(false)
readonly input = signal('')
readonly documentId = signal<number>(undefined)
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@@ -38,7 +45,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false
public get placeholder(): string {
return this.documentId
return this.documentId()
? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...`
}
@@ -57,14 +64,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId = docIdRe ? +docIdRe[1] : undefined
this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
}
sendMessage(): void {
if (!this.input.trim()) return
if (!this.input().trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.push(userMessage)
const userMessage: ChatMessage = { role: 'user', content: this.input() }
this.messages.update((messages) => [...messages, userMessage])
this.scrollToBottom()
const assistantMessage: ChatMessage = {
@@ -72,12 +79,12 @@ export class ChatComponent implements OnInit {
content: '',
isStreaming: true,
}
this.messages.push(assistantMessage)
this.loading = true
this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true)
let lastVisibleContent = ''
this.chatService.streamChat(this.documentId, this.input).subscribe({
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
next: (chunk) => {
const nextResponse = parseChatResponse(chunk)
@@ -93,26 +100,30 @@ export class ChatComponent implements OnInit {
}
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false
this.loading = false
this.notifyMessagesChanged()
this.loading.set(false)
},
complete: () => {
assistantMessage.isStreaming = false
this.loading = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
},
})
this.input = ''
this.input.set('')
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = []
this.typewriterActive = false
message.content = content
this.notifyMessagesChanged()
this.scrollToBottom()
}
@@ -135,11 +146,16 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift()
message.content += nextChar
this.notifyMessagesChanged()
this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
}
private notifyMessagesChanged(): void {
this.messages.update((messages) => [...messages])
}
private scrollToBottom(): void {
setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
@@ -1,5 +1,12 @@
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 { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -22,6 +29,7 @@ export class CustomFieldDisplayComponent
{
private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType
@@ -74,6 +82,7 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results
this.init()
this.changeDetector.markForCheck()
})
}
@@ -111,6 +120,7 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id))
.filter((d) => d)
this.changeDetector.markForCheck()
})
}
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']()
expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields'][0].name).toEqual('Field 2')
expect(component['unusedFields']().length).toEqual(1)
expect(component['unusedFields']()[0].name).toEqual('Field 2')
})
it('should support getting data type label', () => {
@@ -8,6 +8,7 @@ import {
ViewChild,
ViewChildren,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -65,11 +66,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = []
private unusedFields: CustomField[] = []
private readonly unusedFields = signal<CustomField[]>([])
private keyboardIndex: number
public get filteredFields(): CustomField[] {
return this.unusedFields.filter(
return this.unusedFields().filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
)
}
@@ -99,8 +100,10 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
}
private updateUnusedFields() {
this.unusedFields = this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
this.unusedFields.set(
this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
)
}
@@ -77,7 +77,7 @@
<div class="input-group input-group-sm">
<ng-select #fieldSelects
class="paperless-input-select"
[items]="customFields"
[items]="customFields()"
[(ngModel)]="atom.field"
[disabled]="disabled"
bindLabel="name"
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
})
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', () => {
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String,
extra_data: {},
}
component.customFields = [field]
component.customFields.set([field])
const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual(
[
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
],
},
}
component.customFields = [field]
component.customFields.set([field])
const options = component.getSelectOptionsForField(1)
expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' },
@@ -6,6 +6,7 @@ import {
Input,
Output,
QueryList,
signal,
ViewChild,
ViewChildren,
} from '@angular/core'
@@ -278,7 +279,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
customFields: CustomField[] = []
readonly customFields = signal<CustomField[]>([])
public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -325,12 +326,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields = result.results
this.customFields.set(result.results)
})
}
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) {
@@ -353,7 +354,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField(
fieldID: number
): 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
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic]
@@ -369,7 +370,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField(
fieldID: number
): 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) {
return field.extra_data['select_options']
}
@@ -22,11 +22,11 @@
<ng-template>
<div class="card mb-2">
<div class="card-body p-2">
@if (testLoading) {
@if (testLoading()) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult) {
<code>{{testResult}}</code>
} @else if (testFailed) {
} @else if (testResult()) {
<code>{{testResult()}}</code>
} @else if (testFailed()) {
<div class="text-danger" i18n>Path test failed</div>
} @else {
<div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[loading]="loading()"
[typeahead]="documentsInput$"
(change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges()
component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult).toBe('test/abc123')
expect(component.testFailed).toBeFalsy()
expect(component.testResult()).toBe('test/abc123')
expect(component.testFailed()).toBeFalsy()
// test failed
testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 })
expect(component.testResult).toBeNull()
expect(component.testFailed).toBeTruthy()
expect(component.testResult()).toBeNull()
expect(component.testFailed()).toBeTruthy()
component.testPath(null)
expect(component.testResult).toBeNull()
expect(component.testResult()).toBeNull()
})
it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject } from '@angular/core'
import { Component, OnDestroy, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]>
private testDocument: Document
public testResult: string
public testFailed: boolean = false
public testLoading = false
readonly testResult = signal<string>(undefined)
readonly testFailed = signal(false)
readonly testLoading = signal(false)
constructor() {
super()
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) {
if (!document) {
this.testResult = null
this.testResult.set(null)
return
}
this.testDocument = document
this.testLoading = true
this.testLoading.set(true)
;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => {
if (result?.length) {
this.testResult = result
this.testFailed = false
this.testResult.set(result)
this.testFailed.set(false)
} else {
this.testResult = null
this.testFailed = true
this.testResult.set(null)
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-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-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 {
FormControl,
FormGroup,
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
],
})
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
tags: Tag[]
readonly tags = signal<Tag[]>([])
constructor() {
super()
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
this.service.listAll().subscribe((result) => {
this.tags = result.results
this.tags.set(result.results)
})
}
@@ -58,8 +58,8 @@
</button>
}
@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">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<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>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
</button>
}
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id)
fixture.detectChanges()
expect(component.modelIsDirty).toBeTruthy()
expect(component.modelIsDirty()).toBeTruthy()
let applyResult: ChangedItems
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.apply.subscribe((result) => (applyResult = result))
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id)
fixture.detectChanges()
expect(component.modelIsDirty).toBeTruthy()
expect(component.modelIsDirty()).toBeTruthy()
let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result))
component.dropdown.close()
@@ -12,6 +12,7 @@ import {
Output,
ViewChild,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
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
}
modelIsDirty: boolean = false
readonly modelIsDirty = signal(false)
private keyboardIndex: number
@@ -811,7 +812,7 @@ export class FilterableDropdownComponent
constructor() {
super()
this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty()
this.modelIsDirty.set(updatedModel.isDirty())
})
}
@@ -858,7 +859,7 @@ export class FilterableDropdownComponent
}, 0)
if (this.editing) {
this.selectionModel.reset()
this.modelIsDirty = false
this.modelIsDirty.set(false)
}
this.selectionModel.singleSelect =
this.editing && !this.selectionModel.manyToOne
@@ -5,6 +5,7 @@ import {
inject,
Input,
Output,
signal,
} from '@angular/core'
import {
FormsModule,
@@ -63,11 +64,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
super()
customFieldsService.listAll().subscribe((items) => {
this.fields = items.results
this.fields.set(items.results)
})
}
private fields: CustomField[]
private readonly fields = signal<CustomField[]>([])
private _selectedFields: number[]
@@ -90,6 +91,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
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"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[loading]="loading()"
[typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocumentIDs)">
@@ -2,7 +2,11 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
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 { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component'
@@ -33,10 +37,11 @@ describe('DocumentLinkComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [DocumentLinkComponent],
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideRouter([]),
],
})
documentService = TestBed.inject(DocumentService)
@@ -60,6 +65,25 @@ describe('DocumentLinkComponent', () => {
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', () => {
const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => {
@@ -6,6 +6,7 @@ import {
Input,
OnDestroy,
OnInit,
signal,
} from '@angular/core'
import {
FormsModule,
@@ -63,7 +64,7 @@ export class DocumentLinkComponent
documentsInput$ = new Subject<string>()
foundDocuments$: Observable<Document[]>
loading = false
readonly loading = signal(false)
selectedDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
@@ -93,12 +94,12 @@ export class DocumentLinkComponent
this.selectedDocuments = []
super.writeValue([])
} else {
this.loading = true
this.loading.set(true)
this.documentsService
.getFew(documentIDs, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documentResults) => {
this.loading = false
this.loading.set(false)
this.selectedDocuments = documentIDs.map(
(id) => documentResults.results.find((d) => d.id === id) ?? {}
)
@@ -113,7 +114,7 @@ export class DocumentLinkComponent
this.documentsInput$.pipe(
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier),
tap(() => (this.loading = true)),
tap(() => this.loading.set(true)),
switchMap((title) =>
this.documentsService
.listFiltered(
@@ -133,7 +134,7 @@ export class DocumentLinkComponent
)
),
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.value = nextAsn
this.onChange(this.value)
this.changeDetector.markForCheck()
})
}
@@ -68,7 +68,7 @@
[(ngModel)]="selectionModel.includeUsers"
[disabled]="disabled"
[clearable]="false"
[items]="users"
[items]="users()"
bindLabel="username"
multiple="true"
bindValue="id"
@@ -1,5 +1,12 @@
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 { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select'
@@ -75,7 +82,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
@Output()
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
users: User[]
readonly users = signal<User[]>([])
hideUnowned: boolean
@@ -102,7 +109,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
.listAll()
.pipe(first())
.subscribe({
next: (result) => (this.users = result.results),
next: (result) => this.users.set(result.results),
})
}
}
@@ -57,15 +57,15 @@
</div>
</div>
<div class="col-12 col-md-6">
@if (socialAccounts?.length > 0) {
@if (socialAccounts().length > 0) {
<div class="mb-3">
<p i18n>Connected social accounts</p>
<ul class="list-group">
@for (account of socialAccounts; track account.id) {
@for (account of socialAccounts(); track account.id) {
<li class="list-group-item"
ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover
[disablePopover]="hasUsablePassword"
[disablePopover]="hasUsablePassword()"
triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}})
<pngx-confirm-button
@@ -75,7 +75,7 @@
i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash"
[disabled]="!hasUsablePassword"
[disabled]="!hasUsablePassword()"
(confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button>
</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>
}
@if (socialAccountProviders?.length > 0) {
@if (socialAccountProviders().length > 0) {
<div class="mb-3">
<p i18n>Connect new social account</p>
<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">
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
</a>
@@ -96,7 +96,7 @@
</div>
</div>
}
@if (!isTotpEnabled) {
@if (!isTotpEnabled()) {
<div ngbAccordion>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
@@ -105,10 +105,10 @@
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
@if (totpSettingsLoading) {
@if (totpSettingsLoading()) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings) {
} @else if (totpSettings()) {
<figure class="figure">
@if (qrSvgDataUrl) {
<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>
</figure>
<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>
</p>
<div class="input-group mb-3">
<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>
@if (totpLoading) {
@if (totpLoading()) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
}
@@ -137,18 +137,18 @@
</div>
} @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes) {
@if (recoveryCodes()) {
<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>
</div>
<div class="d-flex flex-row align-items-start mb-3">
<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) {
<li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code>
@if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes[i + 1]}}</code>
@if (recoveryCodes()[i + 1]) {
<code>{{recoveryCodes()[i + 1]}}</code>
}
</li>
}
@@ -171,7 +171,7 @@
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
[disabled]="totpLoading()"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
@@ -10,7 +10,7 @@ import {
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
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 { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
@@ -60,6 +60,10 @@ describe('ProfileEditDialogComponent', () => {
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
})
profileService = TestBed.inject(ProfileService)
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
jest
.spyOn(profileService, 'getSocialAccountProviders')
.mockReturnValue(of([]))
toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent)
@@ -155,7 +159,7 @@ describe('ProfileEditDialogComponent', () => {
'getSocialAccountProviders'
)
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
component.hasUsablePassword = true
component.hasUsablePassword.set(true)
component.ngOnInit()
component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({
@@ -268,6 +272,27 @@ describe('ProfileEditDialogComponent', () => {
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', () => {
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
const getSpy = jest.spyOn(profileService, 'get')
@@ -276,7 +301,7 @@ describe('ProfileEditDialogComponent', () => {
const errorSpy = jest.spyOn(toastService, 'showError')
expect(component.socialAccounts).toContainEqual(socialAccount)
expect(component.socialAccounts()).toContainEqual(socialAccount)
// fail first
disconnectSpy.mockReturnValueOnce(
@@ -289,7 +314,7 @@ describe('ProfileEditDialogComponent', () => {
disconnectSpy.mockReturnValue(of(socialAccount.id))
component.disconnectSocialAccount(socialAccount.id)
expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts).not.toContainEqual(socialAccount)
expect(component.socialAccounts()).not.toContainEqual(socialAccount)
})
it('should get totp settings', () => {
@@ -310,7 +335,7 @@ describe('ProfileEditDialogComponent', () => {
getSpy.mockReturnValue(of(settings))
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings).toEqual(settings)
expect(component.totpSettings()).toEqual(settings)
})
it('should activate totp', () => {
@@ -319,15 +344,15 @@ describe('ProfileEditDialogComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings = {
component.totpSettings.set({
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
}
})
component.form.get('totp_code').patchValue('123456')
component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings.secret,
component.totpSettings().secret,
component.form.get('totp_code').value
)
expect(toastErrorSpy).toHaveBeenCalled()
@@ -341,8 +366,8 @@ describe('ProfileEditDialogComponent', () => {
)
component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
expect(component.isTotpEnabled()).toBeTruthy()
expect(component.recoveryCodes()).toEqual(['1', '2', '3'])
})
it('should deactivate totp', () => {
@@ -362,13 +387,13 @@ describe('ProfileEditDialogComponent', () => {
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeFalsy()
expect(component.isTotpEnabled()).toBeFalsy()
})
it('should copy recovery codes', () => {
jest.useFakeTimers()
const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes = ['1', '2', '3']
component.recoveryCodes.set(['1', '2', '3'])
component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
jest.advanceTimersByTime(3000)
@@ -56,6 +56,14 @@ export class ProfileEditDialogComponent
readonly showEmailConfirm = signal(false)
readonly copied = 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({
email: new FormControl(''),
@@ -72,25 +80,15 @@ export class ProfileEditDialogComponent
private newPassword: string
private passwordConfirm: string
public hasUsablePassword: boolean = false
private currentEmail: string
private newEmail: 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 {
if (!this.totpSettings?.qr_svg) {
if (!this.totpSettings()?.qr_svg) {
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 {
@@ -107,20 +105,20 @@ export class ProfileEditDialogComponent
this.onEmailChange()
})
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.newPassword = newPassword
this.onPasswordChange()
})
this.socialAccounts = profile.social_accounts
this.isTotpEnabled = profile.is_mfa_enabled
this.socialAccounts.set(profile.social_accounts ?? [])
this.isTotpEnabled.set(profile.is_mfa_enabled)
})
this.profileService
.getSocialAccountProviders()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((providers) => {
this.socialAccountProviders = providers
this.socialAccountProviders.set(providers ?? [])
})
}
@@ -259,7 +257,9 @@ export class ProfileEditDialogComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (id: number) => {
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
this.socialAccounts.update((accounts) =>
accounts.filter((account) => account.id != id)
)
},
error: (error) => {
this.toastService.showError(
@@ -271,36 +271,39 @@ export class ProfileEditDialogComponent
}
public gettotpSettings(): void {
this.totpSettingsLoading = true
this.totpSettingsLoading.set(true)
this.profileService
.getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (totpSettings) => {
this.totpSettingsLoading = false
this.totpSettings = totpSettings
this.totpSettingsLoading.set(false)
this.totpSettings.set(totpSettings)
},
error: (error) => {
this.toastService.showError(
$localize`Error fetching TOTP settings`,
error
)
this.totpSettingsLoading = false
this.totpSettingsLoading.set(false)
},
})
}
public activateTotp(): void {
this.totpLoading = true
this.totpLoading.set(true)
this.form.get('totp_code').disable()
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))
.subscribe({
next: (activationResponse) => {
this.totpLoading = false
this.isTotpEnabled = activationResponse.success
this.recoveryCodes = activationResponse.recovery_codes
this.totpLoading.set(false)
this.isTotpEnabled.set(activationResponse.success)
this.recoveryCodes.set(activationResponse.recovery_codes)
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`)
@@ -309,7 +312,7 @@ export class ProfileEditDialogComponent
}
},
error: (error) => {
this.totpLoading = false
this.totpLoading.set(false)
this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error)
},
@@ -317,15 +320,15 @@ export class ProfileEditDialogComponent
}
public deactivateTotp(): void {
this.totpLoading = true
this.totpLoading.set(true)
this.profileService
.deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (success) => {
this.totpLoading = false
this.isTotpEnabled = !success
this.recoveryCodes = null
this.totpLoading.set(false)
this.isTotpEnabled.set(!success)
this.recoveryCodes.set(null)
if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else {
@@ -333,14 +336,14 @@ export class ProfileEditDialogComponent
}
},
error: (error) => {
this.totpLoading = false
this.totpLoading.set(false)
this.toastService.showError($localize`Error deactivating TOTP`, error)
},
})
}
public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes.join('\n'))
this.clipboard.copy(this.recoveryCodes().join('\n'))
this.codesCopied.set(true)
setTimeout(() => {
this.codesCopied.set(false)
@@ -14,7 +14,9 @@
}
<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">
<thead>
<tr>
@@ -1,6 +1,10 @@
import { DragDropModule } from '@angular/cdk/drag-drop'
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 { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
@@ -8,7 +12,7 @@ import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
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 { 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'
@@ -230,6 +234,27 @@ describe('SavedViewWidgetComponent', () => {
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', () => {
const fileStatusSubject = new Subject<FileStatus>()
jest
@@ -125,6 +125,8 @@ export class SavedViewWidgetComponent
readonly count = signal<number>(null)
readonly error = signal<string | null>(null)
placeholderRows: number[] = []
ngOnInit(): void {
@@ -180,6 +182,7 @@ export class SavedViewWidgetComponent
reload() {
this.loading.set(this.documents().length == 0)
this.error.set(null)
this.show.set(true)
this.documentService
.listFiltered(
@@ -191,12 +194,40 @@ export class SavedViewWidgetComponent
{ truncate_content: true }
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.documents.set(result.results)
this.count.set(result.count)
this.savedViewService.setDocumentCount(this.savedView, result.count)
this.loading.set(false)
this.show.set(true)
.subscribe({
next: (result) => {
this.documents.set(result.results)
this.count.set(result.count)
this.savedViewService.setDocumentCount(this.savedView, result.count)
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 { FILTER_ASN } from '../../data/filter-rule-type'
import { DocumentService } from '../../services/rest/document.service'
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
private route = inject(ActivatedRoute)
private router = inject(Router)
asn: string
readonly asn = signal<string>(undefined)
ngOnInit(): void {
this.route.paramMap.subscribe((paramMap) => {
this.asn = paramMap.get('id')
this.asn.set(paramMap.get('id'))
this.documentsService
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }])
.subscribe((documentId) => {
if (documentId.length == 1) {
this.router.navigate(['documents', documentId[0]])
@@ -482,9 +482,7 @@
</pngx-pdf-viewer>
</div>
} @else {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
}
}
@case (ContentRenderType.Text) {
@@ -505,9 +503,7 @@
}
}
@case (ContentRenderType.Other) {
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
<span>Preview is unavailable.</span>
</object>
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
}
}
@if (requiresPassword) {
@@ -5,7 +5,7 @@
</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<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">
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
<input
@@ -32,7 +32,7 @@
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button>
} @else {
@switch (versionUploadState) {
@switch (versionUploadState()) {
@case (UploadState.Uploading) {
<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>
@@ -50,8 +50,8 @@
<span i18n>Version upload failed.</span>
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
</div>
@if (versionUploadError) {
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
@if (versionUploadError()) {
<div class="small text-muted mt-1">{{ versionUploadError() }}</div>
}
}
}
@@ -83,7 +83,7 @@
[(ngModel)]="versionLabelDraft"
i18n-placeholder
placeholder="Version label"
[disabled]="savingVersionLabelId !== null"
[disabled]="savingVersionLabelId() !== null"
(keydown.enter)="submitEditedVersionLabel(version, $event)"
(keydown.escape)="cancelEditingVersion($event)"
(click)="$event.stopPropagation()"
@@ -101,7 +101,7 @@
<button
type="button"
class="btn btn-outline-secondary"
[disabled]="savingVersionLabelId !== null"
[disabled]="savingVersionLabelId() !== null"
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
>
@if (isEditingVersion(version.id)) {
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
{ id: 3, is_root: true, checksum: 'aaaa' },
{ 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', () => {
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
'Error updating version label',
error
)
expect(component.savingVersionLabelId).toBeNull()
expect(component.savingVersionLabelId()).toBeNull()
})
it('onVersionFileSelected should upload and update versions after websocket success', () => {
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
expect(component.newVersionLabel).toEqual('')
expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
expect(component.versionUploadState()).toEqual(UploadState.Idle)
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', {
type: 'application/pdf',
})
@@ -266,9 +266,11 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event)
failed$.next({ taskId: 'task-1', message: 'processing failed' })
await fixture.whenStable()
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('processing failed')
expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('processing failed')
expect(fixture.nativeElement.textContent).toContain('processing failed')
expect(documentService.getVersions).not.toHaveBeenCalled()
})
@@ -282,8 +284,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('Missing task ID.')
expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('Missing task ID.')
expect(documentService.getVersions).not.toHaveBeenCalled()
})
@@ -298,8 +300,8 @@ describe('DocumentVersionDropdownComponent', () => {
component.onVersionFileSelected({ target: input } as Event)
expect(component.versionUploadState).toEqual(UploadState.Failed)
expect(component.versionUploadError).toEqual('upload failed')
expect(component.versionUploadState()).toEqual(UploadState.Failed)
expect(component.versionUploadError()).toEqual('upload failed')
expect(toastService.showError).toHaveBeenCalledWith(
'Error uploading new version',
error
@@ -307,8 +309,8 @@ describe('DocumentVersionDropdownComponent', () => {
})
it('ngOnChanges should clear upload status on document switch', () => {
component.versionUploadState = UploadState.Failed
component.versionUploadError = 'something failed'
component.versionUploadState.set(UploadState.Failed)
component.versionUploadError.set('something failed')
component.editingVersionId = 10
component.versionLabelDraft = 'draft'
@@ -316,8 +318,8 @@ describe('DocumentVersionDropdownComponent', () => {
documentId: new SimpleChange(3, 4, false),
})
expect(component.versionUploadState).toEqual(UploadState.Idle)
expect(component.versionUploadError).toBeNull()
expect(component.versionUploadState()).toEqual(UploadState.Idle)
expect(component.versionUploadError()).toBeNull()
expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('')
})
@@ -7,6 +7,7 @@ import {
OnChanges,
OnDestroy,
Output,
signal,
SimpleChanges,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
@@ -59,9 +60,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
newVersionLabel: string = ''
versionUploadState: UploadState = UploadState.Idle
versionUploadError: string | null = null
savingVersionLabelId: number | null = null
readonly versionUploadState = signal(UploadState.Idle)
readonly versionUploadError = signal<string | null>(null)
readonly savingVersionLabelId = signal<number | null>(null)
editingVersionId: number | null = null
versionLabelDraft: string = ''
@@ -101,7 +102,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return
this.editingVersionId = version.id
this.versionLabelDraft = version.version_label ?? ''
}
@@ -116,7 +117,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
if (this.savingVersionLabelId !== null) return
if (this.savingVersionLabelId() !== null) return
const nextLabel = this.versionLabelDraft?.trim() || null
const currentLabel = version.version_label?.trim() || null
if (nextLabel === currentLabel) {
@@ -158,15 +159,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}
saveVersionLabel(versionId: number, versionLabel: string | null): void {
if (this.savingVersionLabelId !== null) return
this.savingVersionLabelId = versionId
if (this.savingVersionLabelId() !== null) return
this.savingVersionLabelId.set(versionId)
this.documentsService
.updateVersionLabel(this.documentId, versionId, versionLabel)
.pipe(
first(),
finalize(() => {
if (this.savingVersionLabelId === versionId) {
this.savingVersionLabelId = null
if (this.savingVersionLabelId() === versionId) {
this.savingVersionLabelId.set(null)
}
}),
takeUntil(this.destroy$)
@@ -199,8 +200,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
const file = input.files[0]
input.value = ''
const label = this.newVersionLabel?.trim()
this.versionUploadState = UploadState.Uploading
this.versionUploadError = null
this.versionUploadState.set(UploadState.Uploading)
this.versionUploadError.set(null)
this.documentsService
.uploadVersion(uploadDocumentId, file, label)
.pipe(
@@ -210,7 +211,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
$localize`Uploading new version. Processing will happen in the background.`
)
this.newVersionLabel = ''
this.versionUploadState = UploadState.Processing
this.versionUploadState.set(UploadState.Processing)
}),
map((taskId) =>
typeof taskId === 'string'
@@ -219,8 +220,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
),
switchMap((taskId) => {
if (!taskId) {
this.versionUploadState = UploadState.Failed
this.versionUploadError = $localize`Missing task ID.`
this.versionUploadState.set(UploadState.Failed)
this.versionUploadError.set($localize`Missing task ID.`)
return of(null)
}
return merge(
@@ -240,9 +241,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
switchMap((result) => {
if (result?.state !== 'success') {
if (result?.state === 'failed') {
this.versionUploadState = UploadState.Failed
this.versionUploadError =
this.versionUploadState.set(UploadState.Failed)
this.versionUploadError.set(
result.message || $localize`Upload failed.`
)
}
return of(null)
}
@@ -264,8 +266,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
},
error: (error) => {
if (uploadDocumentId !== this.documentId) return
this.versionUploadState = UploadState.Failed
this.versionUploadError = error?.message || $localize`Upload failed.`
this.versionUploadState.set(UploadState.Failed)
this.versionUploadError.set(
error?.message || $localize`Upload failed.`
)
this.toastService.showError(
$localize`Error uploading new version`,
error
@@ -275,7 +279,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
}
clearVersionUploadStatus(): void {
this.versionUploadState = UploadState.Idle
this.versionUploadError = null
this.versionUploadState.set(UploadState.Idle)
this.versionUploadError.set(null)
}
}
@@ -10,7 +10,7 @@
[createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()"
[(selectionModel)]="tagSelectionModel"
[documentCounts]="tagDocumentCounts"
[documentCounts]="tagDocumentCounts()"
(apply)="setTags($event)"
shortcutKey="t">
</pngx-filterable-dropdown>
@@ -24,7 +24,7 @@
[createRef]="createCorrespondent.bind(this)"
(opened)="openCorrespondentDropdown()"
[(selectionModel)]="correspondentSelectionModel"
[documentCounts]="correspondentDocumentCounts"
[documentCounts]="correspondentDocumentCounts()"
(apply)="setCorrespondents($event)"
shortcutKey="y">
</pngx-filterable-dropdown>
@@ -38,7 +38,7 @@
[createRef]="createDocumentType.bind(this)"
(opened)="openDocumentTypeDropdown()"
[(selectionModel)]="documentTypeSelectionModel"
[documentCounts]="documentTypeDocumentCounts"
[documentCounts]="documentTypeDocumentCounts()"
(apply)="setDocumentTypes($event)"
shortcutKey="u">
</pngx-filterable-dropdown>
@@ -52,7 +52,7 @@
[createRef]="createStoragePath.bind(this)"
(opened)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
[documentCounts]="storagePathDocumentCounts"
[documentCounts]="storagePathDocumentCounts()"
(apply)="setStoragePaths($event)"
shortcutKey="i">
</pngx-filterable-dropdown>
@@ -66,7 +66,7 @@
[createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts"
[documentCounts]="customFieldDocumentCounts()"
extraButtonTitle="Set values"
i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)"
@@ -124,11 +124,11 @@
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@if (!awaitingDownload) {
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
@if (!awaitingDownload()) {
<i-bs name="arrow-down"></i-bs>
}
@if (awaitingDownload) {
@if (awaitingDownload()) {
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
</div>
@@ -303,7 +303,7 @@ describe('BulkEditorComponent', () => {
component.openDocumentTypeDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.documentTypeDocumentCounts).toEqual(
expect(component.documentTypeDocumentCounts()).toEqual(
selectionData.selected_document_types
)
})
@@ -320,7 +320,7 @@ describe('BulkEditorComponent', () => {
component.openCorrespondentDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.correspondentDocumentCounts).toEqual(
expect(component.correspondentDocumentCounts()).toEqual(
selectionData.selected_correspondents
)
})
@@ -337,7 +337,7 @@ describe('BulkEditorComponent', () => {
component.openStoragePathDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.storagePathDocumentCounts).toEqual(
expect(component.storagePathDocumentCounts()).toEqual(
selectionData.selected_storage_paths
)
})
@@ -354,7 +354,7 @@ describe('BulkEditorComponent', () => {
component.openCustomFieldsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.customFieldDocumentCounts).toEqual(
expect(component.customFieldDocumentCounts()).toEqual(
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 {
FormControl,
FormGroup,
@@ -101,12 +108,12 @@ export class BulkEditorComponent
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
readonly awaitingDownload = signal(false)
unsubscribeNotifier: Subject<any> = new Subject()
@@ -365,8 +372,8 @@ export class BulkEditorComponent
openTagsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
return
}
@@ -374,7 +381,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.tagDocumentCounts = s.selected_tags
this.tagDocumentCounts.set(s.selected_tags)
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
})
}
@@ -382,10 +389,11 @@ export class BulkEditorComponent
openDocumentTypeDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.documentTypeDocumentCounts =
this.documentTypeDocumentCounts.set(
selectionData?.selected_document_types ?? []
)
this.applySelectionData(
this.documentTypeDocumentCounts,
this.documentTypeDocumentCounts(),
this.documentTypeSelectionModel
)
return
@@ -395,7 +403,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.documentTypeDocumentCounts = s.selected_document_types
this.documentTypeDocumentCounts.set(s.selected_document_types)
this.applySelectionData(
s.selected_document_types,
this.documentTypeSelectionModel
@@ -406,10 +414,11 @@ export class BulkEditorComponent
openCorrespondentDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.correspondentDocumentCounts =
this.correspondentDocumentCounts.set(
selectionData?.selected_correspondents ?? []
)
this.applySelectionData(
this.correspondentDocumentCounts,
this.correspondentDocumentCounts(),
this.correspondentSelectionModel
)
return
@@ -419,7 +428,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.correspondentDocumentCounts = s.selected_correspondents
this.correspondentDocumentCounts.set(s.selected_correspondents)
this.applySelectionData(
s.selected_correspondents,
this.correspondentSelectionModel
@@ -430,10 +439,11 @@ export class BulkEditorComponent
openStoragePathDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.storagePathDocumentCounts =
this.storagePathDocumentCounts.set(
selectionData?.selected_storage_paths ?? []
)
this.applySelectionData(
this.storagePathDocumentCounts,
this.storagePathDocumentCounts(),
this.storagePathsSelectionModel
)
return
@@ -443,7 +453,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.storagePathDocumentCounts = s.selected_storage_paths
this.storagePathDocumentCounts.set(s.selected_storage_paths)
this.applySelectionData(
s.selected_storage_paths,
this.storagePathsSelectionModel
@@ -454,10 +464,11 @@ export class BulkEditorComponent
openCustomFieldsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.customFieldDocumentCounts =
this.customFieldDocumentCounts.set(
selectionData?.selected_custom_fields ?? []
)
this.applySelectionData(
this.customFieldDocumentCounts,
this.customFieldDocumentCounts(),
this.customFieldsSelectionModel
)
return
@@ -467,7 +478,7 @@ export class BulkEditorComponent
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.customFieldDocumentCounts = s.selected_custom_fields
this.customFieldDocumentCounts.set(s.selected_custom_fields)
this.applySelectionData(
s.selected_custom_fields,
this.customFieldsSelectionModel
@@ -876,7 +887,7 @@ export class BulkEditorComponent
}
downloadSelected() {
this.awaitingDownload = true
this.awaitingDownload.set(true)
let downloadFileType: string =
this.downloadForm.get('downloadFileTypeArchive').value &&
this.downloadForm.get('downloadFileTypeOriginals').value
@@ -893,7 +904,7 @@ export class BulkEditorComponent
.pipe(first())
.subscribe((result: any) => {
saveAs(result, 'documents.zip')
this.awaitingDownload = false
this.awaitingDownload.set(false)
})
}
@@ -115,7 +115,7 @@
</pngx-page-header>
<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>
</div>
@@ -5,6 +5,7 @@ import {
OnDestroy,
OnInit,
QueryList,
signal,
ViewChild,
ViewChildren,
} from '@angular/core'
@@ -149,7 +150,7 @@ export class DocumentListComponent
)
}
unmodifiedFilterRules: FilterRule[] = []
readonly unmodifiedFilterRules = signal<FilterRule[]>([])
private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null
@@ -299,7 +300,7 @@ export class DocumentListComponent
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
})
this.updateDisplayCustomFields()
this.unmodifiedFilterRules = view.filter_rules
this.unmodifiedFilterRules.set(view.filter_rules)
})
this.route.queryParamMap
@@ -316,7 +317,7 @@ export class DocumentListComponent
this.activeSavedView = null
this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules = []
this.unmodifiedFilterRules.set([])
}
})
@@ -415,7 +416,7 @@ export class DocumentListComponent
this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
)
this.unmodifiedFilterRules = this.list.filterRules
this.unmodifiedFilterRules.set(this.list.filterRules)
},
error: (err) => {
this.toastService.showError(
@@ -80,7 +80,7 @@
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
[(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()"
@@ -1,5 +1,6 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
@@ -9,6 +10,7 @@ import {
Output,
ViewChild,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -349,7 +351,7 @@ export class FilterEditorComponent
@ViewChild('textFilterInput')
textFilterInput: ElementRef
customFields: CustomField[] = []
readonly customFields = signal<CustomField[]>([])
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
@@ -514,6 +516,7 @@ export class FilterEditorComponent
this.documentService.get(this._moreLikeId).subscribe((result) => {
this._moreLikeDoc = result
this._textFilter = result.title
this.changeDetector.markForCheck()
})
break
case FILTER_CREATED_AFTER:
@@ -1162,6 +1165,7 @@ export class FilterEditorComponent
private loadingCountTotal: number = 0
private loadingCount: number = 0
private readonly changeDetector = inject(ChangeDetectorRef)
private maybeCompleteLoading() {
this.loadingCount++
@@ -1229,7 +1233,7 @@ export class FilterEditorComponent
) {
this.loadingCountTotal++
this.customFieldService.listAll().subscribe((result) => {
this.customFields = result.results
this.customFields.set(result.results)
this.maybeCompleteLoading()
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43
View File
@@ -177,6 +177,49 @@ class TestViews(DirectoriesMixin, TestCase):
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
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:
"""
GIVEN:
+2 -1
View File
@@ -4534,7 +4534,8 @@ class SharedLinkView(View):
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
return serve_file(
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",
)
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"Last-Translator: \n"
"Language-Team: Afrikaans\n"
"Language: af_ZA\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1052
#: documents/filters.py:1059
msgid "Custom field not found"
msgstr ""
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4554
#: documents/views.py:4555
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4564
#: documents/views.py:4565
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"Last-Translator: \n"
"Language-Team: Amharic\n"
"Language: am_ET\n"
@@ -53,7 +53,7 @@ msgstr "ጥያቄን አይደግፍም expr {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr "ከፍተኛው የጥገኝነት ጥልቀት አልፏል።"
#: documents/filters.py:1052
#: documents/filters.py:1059
msgid "Custom field not found"
msgstr "ይህ ልማድ አልተገኘም"
@@ -1659,11 +1659,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4554
#: documents/views.py:4555
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4564
#: documents/views.py:4565
msgid "The share link bundle is unavailable."
msgstr ""
+5 -5
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
"PO-Revision-Date: 2026-07-15 20:45\n"
"POT-Creation-Date: 2026-07-19 20:55+0000\n"
"PO-Revision-Date: 2026-07-19 20:57\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Language: ar_SA\n"
@@ -53,7 +53,7 @@ msgstr "{data_type} لا يدعم تعبير الاستعلام {expr!r}."
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1052
#: documents/filters.py:1059
msgid "Custom field not found"
msgstr "لم يتم العثور على حقل مخصص"
@@ -1660,11 +1660,11 @@ msgstr ""
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4554
#: documents/views.py:4555
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4564
#: documents/views.py:4565
msgid "The share link bundle is unavailable."
msgstr ""

Some files were not shown because too many files have changed in this diff Show More