mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-18 18:04:53 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcba44ad78 | ||
|
|
ed54f1a8b9 | ||
|
|
f8a641b402 | ||
|
|
e85223929c |
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
+11
-1
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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', () => {
|
||||
|
||||
+7
-4
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+3
-3
@@ -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
-5
@@ -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']
|
||||
}
|
||||
|
||||
+5
-5
@@ -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>
|
||||
|
||||
+5
-5
@@ -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', () => {
|
||||
|
||||
+11
-11
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+3
-3
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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>
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+4
-3
@@ -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)">
|
||||
|
||||
+26
-2
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@
|
||||
[(ngModel)]="selectionModel.includeUsers"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[items]="users"
|
||||
[items]="users()"
|
||||
bindLabel="username"
|
||||
multiple="true"
|
||||
bindValue="id"
|
||||
|
||||
+10
-3
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+17
-17
@@ -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>
|
||||
}
|
||||
|
||||
+37
-12
@@ -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)
|
||||
|
||||
+36
-33
@@ -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)
|
||||
|
||||
@@ -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]])
|
||||
|
||||
+6
-6
@@ -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)) {
|
||||
|
||||
+17
-15
@@ -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('')
|
||||
})
|
||||
|
||||
+24
-20
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user