mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-23 12:24:55 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d9c52cde | ||
|
|
cdb11b94b5 | ||
|
|
cfd7a3a7f0 | ||
|
|
c6329f4b76 | ||
|
|
5e06987102 | ||
|
|
3f93557934 | ||
|
|
afe6ad2192 | ||
|
|
a3f9fa0369 | ||
|
|
e106618c39 | ||
|
|
d113826cf8 | ||
|
|
c063b3799f | ||
|
|
81795bc93a | ||
|
|
6664f05543 | ||
|
|
8aab34ea73 | ||
|
|
8404198ec8 | ||
|
|
d8d8872414 | ||
|
|
ff609c2987 | ||
|
|
2b784e709b | ||
|
|
c9716252f0 | ||
|
|
5cf9152a40 | ||
|
|
80210bd3bf | ||
|
|
4e52dd5710 | ||
|
|
d34ef75786 | ||
|
|
b8c424c8e1 | ||
|
|
ec8991c2ec | ||
|
|
4f6d9fa93f | ||
|
|
2180b21c41 | ||
|
|
99bdfdfe7a | ||
|
|
73a0586a75 | ||
|
|
dcba44ad78 | ||
|
|
ed54f1a8b9 | ||
|
|
f8a641b402 | ||
|
|
e85223929c | ||
|
|
f2e3304576 | ||
|
|
6938fb0e01 | ||
|
|
255c214d0b | ||
|
|
a0ab05a9a6 | ||
|
|
df1ddb15cc | ||
|
|
5589a584b6 | ||
|
|
fd0168ac6f |
@@ -26,6 +26,7 @@ jobs:
|
|||||||
check-name: 'Merge and Push Manifest'
|
check-name: 'Merge and Push Manifest'
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
wait-interval: 60
|
wait-interval: 60
|
||||||
|
checks-discovery-timeout: 1800
|
||||||
build-release:
|
build-release:
|
||||||
name: Build Release
|
name: Build Release
|
||||||
needs: wait-for-docker
|
needs: wait-for-docker
|
||||||
|
|||||||
+11
-9
@@ -1159,19 +1159,21 @@ still perform some basic text pre-processing before matching.
|
|||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
Specifies which language Paperless should use when parsing dates from documents.
|
: Specifies which language Paperless should use when parsing dates from documents.
|
||||||
|
|
||||||
This should be a language code supported by the dateparser library,
|
: This should be a language code supported by the dateparser library,
|
||||||
for example: "en", or a combination such as "en+de".
|
for example: "en", or a combination such as "en+de".
|
||||||
Locales are also supported (e.g., "en-AU").
|
Locales are also supported (e.g., "en-AU").
|
||||||
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
|
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
|
||||||
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
|
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
|
||||||
|
|
||||||
|
: Set this to match the languages in which most of your documents are written.
|
||||||
|
|
||||||
Set this to match the languages in which most of your documents are written.
|
|
||||||
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
|
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
|
|
||||||
|
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
|
||||||
|
|
||||||
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
|
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
|
|||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set
|
||||||
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings.
|
||||||
|
|
||||||
### Two-factor authentication
|
### Two-factor authentication
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
"bleach~=6.3.0",
|
"bleach~=6.4.0",
|
||||||
"celery[redis]~=5.6.2",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
@@ -56,7 +56,7 @@ dependencies = [
|
|||||||
"llama-index-embeddings-openai-like>=0.2.2",
|
"llama-index-embeddings-openai-like>=0.2.2",
|
||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf~=17.4.2",
|
"ocrmypdf~=17.4.2",
|
||||||
"openai>=2.32",
|
"openai>=2.32",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
|
|||||||
+222
-199
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
|
||||||
<li [ngbNavItem]="SettingsNavIDs.General">
|
<li [ngbNavItem]="SettingsNavIDs.General">
|
||||||
<a ngbNavLink i18n>General</a>
|
<a ngbNavLink i18n>General</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
|
|||||||
activatedRoute.snapshot.fragment = '#notifications'
|
activatedRoute.snapshot.fragment = '#notifications'
|
||||||
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.activeNavID).toEqual(4) // Notifications
|
expect(component.activeNavID()).toEqual(4) // Notifications
|
||||||
component.ngAfterViewInit()
|
component.ngAfterViewInit()
|
||||||
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class SettingsComponent
|
|||||||
private systemStatusService = inject(SystemStatusService)
|
private systemStatusService = inject(SystemStatusService)
|
||||||
private savedViewsService = inject(SavedViewService)
|
private savedViewsService = inject(SavedViewService)
|
||||||
|
|
||||||
activeNavID: number
|
readonly activeNavID = signal<number>(undefined)
|
||||||
|
|
||||||
settingsForm = new FormGroup({
|
settingsForm = new FormGroup({
|
||||||
bulkEditConfirmationDialogs: new FormControl(null),
|
bulkEditConfirmationDialogs: new FormControl(null),
|
||||||
@@ -283,7 +283,7 @@ export class SettingsComponent
|
|||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
this.activeNavID = SettingsNavIDs[navIDKey]
|
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -386,7 +386,7 @@ export class SettingsComponent
|
|||||||
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
||||||
.then((navigated) => {
|
.then((navigated) => {
|
||||||
if (!navigated && this.isDirty) {
|
if (!navigated && this.isDirty) {
|
||||||
this.activeNavID = navChangeEvent.activeId
|
this.activeNavID.set(navChangeEvent.activeId)
|
||||||
} else if (navigated && this.isDirty) {
|
} else if (navigated && this.isDirty) {
|
||||||
this.initialize()
|
this.initialize()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="chat-container bg-light p-2">
|
||||||
<div class="chat-messages font-monospace small">
|
<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="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'">
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
<span>
|
<span>
|
||||||
@@ -36,11 +36,12 @@
|
|||||||
id="chatInput"
|
id="chatInput"
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
class="form-control form-control-sm" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading"
|
[disabled]="loading()"
|
||||||
[(ngModel)]="input"
|
[ngModel]="input()"
|
||||||
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,44 +56,53 @@ describe('ChatComponent', () => {
|
|||||||
it('should update documentId on initialization', () => {
|
it('should update documentId on initialization', () => {
|
||||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.documentId).toBe(123)
|
expect(component.documentId()).toBe(123)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update documentId on navigation', () => {
|
it('should update documentId on navigation', () => {
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
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', () => {
|
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...')
|
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...')
|
expect(component.placeholder).toBe('Ask a question about a document...')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should send a message and handle streaming response', () => {
|
it('should send a message and render the streaming response', async () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
expect(component.messages).toHaveLength(2)
|
expect(component.messages()).toHaveLength(2)
|
||||||
expect(component.messages[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
jest.advanceTimersByTime(1000)
|
await jest.runAllTimersAsync()
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
await fixture.whenStable()
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.chat-messages').textContent
|
||||||
|
).toContain('Hi there')
|
||||||
|
|
||||||
mockStream$.complete()
|
mockStream$.complete()
|
||||||
expect(component.loading).toBe(false)
|
await jest.runAllTimersAsync()
|
||||||
expect(component.messages[1].isStreaming).toBe(false)
|
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', () => {
|
it('should parse references from the metadata trailer without showing it', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
@@ -101,14 +110,14 @@ describe('ChatComponent', () => {
|
|||||||
)
|
)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
|
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
expect(component.messages[1].references).toEqual([
|
expect(component.messages()[1].references).toEqual([
|
||||||
{ id: 42, title: 'Bread Recipe' },
|
{ id: 42, title: 'Bread Recipe' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render document reference links under assistant messages', () => {
|
it('should render document reference links under assistant messages', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
@@ -123,12 +132,12 @@ describe('ChatComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should remove delimiter fragments that were already streamed', () => {
|
it('should remove delimiter fragments that were already streamed', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
expect(component.messages[1].content).toBe(
|
expect(component.messages()[1].content).toBe(
|
||||||
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,21 +146,21 @@ describe('ChatComponent', () => {
|
|||||||
)
|
)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
|
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
expect(component.messages[1].references).toEqual([
|
expect(component.messages()[1].references).toEqual([
|
||||||
{ id: 42, title: 'Bread Recipe' },
|
{ id: 42, title: 'Bread Recipe' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle errors during streaming', () => {
|
it('should handle errors during streaming', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages[1].content).toContain(
|
expect(component.messages()[1].content).toContain(
|
||||||
'⚠️ Error receiving response.'
|
'⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
@@ -166,7 +175,7 @@ describe('ChatComponent', () => {
|
|||||||
ChatComponent.prototype as any,
|
ChatComponent.prototype as any,
|
||||||
'scrollToBottom'
|
'scrollToBottom'
|
||||||
)
|
)
|
||||||
component.input = 'Test'
|
component.input.set('Test')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
expect(scrollSpy).toHaveBeenCalled()
|
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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -23,10 +30,10 @@ import {
|
|||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
})
|
})
|
||||||
export class ChatComponent implements OnInit {
|
export class ChatComponent implements OnInit {
|
||||||
public messages: ChatMessage[] = []
|
readonly messages = signal<ChatMessage[]>([])
|
||||||
public loading = false
|
readonly loading = signal(false)
|
||||||
public input: string = ''
|
readonly input = signal('')
|
||||||
public documentId!: number
|
readonly documentId = signal<number>(undefined)
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -38,7 +45,7 @@ export class ChatComponent implements OnInit {
|
|||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId
|
return this.documentId()
|
||||||
? $localize`Ask a question about this document...`
|
? $localize`Ask a question about this document...`
|
||||||
: $localize`Ask a question about a document...`
|
: $localize`Ask a question about a document...`
|
||||||
}
|
}
|
||||||
@@ -57,14 +64,14 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private updateDocumentId(url: string): void {
|
private updateDocumentId(url: string): void {
|
||||||
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
||||||
this.documentId = docIdRe ? +docIdRe[1] : undefined
|
this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(): void {
|
sendMessage(): void {
|
||||||
if (!this.input.trim()) return
|
if (!this.input().trim()) return
|
||||||
|
|
||||||
const userMessage: ChatMessage = { role: 'user', content: this.input }
|
const userMessage: ChatMessage = { role: 'user', content: this.input() }
|
||||||
this.messages.push(userMessage)
|
this.messages.update((messages) => [...messages, userMessage])
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
const assistantMessage: ChatMessage = {
|
||||||
@@ -72,12 +79,12 @@ export class ChatComponent implements OnInit {
|
|||||||
content: '',
|
content: '',
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
}
|
}
|
||||||
this.messages.push(assistantMessage)
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId, this.input).subscribe({
|
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||||
next: (chunk) => {
|
next: (chunk) => {
|
||||||
const nextResponse = parseChatResponse(chunk)
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
@@ -93,26 +100,30 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.references = nextResponse.references
|
assistantMessage.references = nextResponse.references
|
||||||
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.isStreaming = false
|
||||||
this.loading = false
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.isStreaming = false
|
||||||
this.loading = false
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.input = ''
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
message.content = content
|
message.content = content
|
||||||
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,11 +146,16 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
const nextChar = this.typewriterBuffer.shift()
|
const nextChar = this.typewriterBuffer.shift()
|
||||||
message.content += nextChar
|
message.content += nextChar
|
||||||
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private notifyMessagesChanged(): void {
|
||||||
|
this.messages.update((messages) => [...messages])
|
||||||
|
}
|
||||||
|
|
||||||
private scrollToBottom(): void {
|
private scrollToBottom(): void {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,12 @@
|
|||||||
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
||||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
LOCALE_ID,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -22,6 +29,7 @@ export class CustomFieldDisplayComponent
|
|||||||
{
|
{
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
|
private changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
CustomFieldDataType = CustomFieldDataType
|
CustomFieldDataType = CustomFieldDataType
|
||||||
|
|
||||||
@@ -74,6 +82,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.customFieldService.listAll().subscribe((r) => {
|
this.customFieldService.listAll().subscribe((r) => {
|
||||||
this.customFields = r.results
|
this.customFields = r.results
|
||||||
this.init()
|
this.init()
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.docLinkDocuments = this.value
|
this.docLinkDocuments = this.value
|
||||||
.map((id) => result.results.find((d) => d.id === id))
|
.map((id) => result.results.find((d) => d.id === id))
|
||||||
.filter((d) => d)
|
.filter((d) => d)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
it('should support update unused fields', () => {
|
it('should support update unused fields', () => {
|
||||||
component.existingFields = [{ field: fields[0].id } as any]
|
component.existingFields = [{ field: fields[0].id } as any]
|
||||||
component['updateUnusedFields']()
|
component['updateUnusedFields']()
|
||||||
expect(component['unusedFields'].length).toEqual(1)
|
expect(component['unusedFields']().length).toEqual(1)
|
||||||
expect(component['unusedFields'][0].name).toEqual('Field 2')
|
expect(component['unusedFields']()[0].name).toEqual('Field 2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support getting data type label', () => {
|
it('should support getting data type label', () => {
|
||||||
|
|||||||
+7
-4
@@ -8,6 +8,7 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -65,11 +66,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
@ViewChildren('button') buttons: QueryList<ElementRef>
|
@ViewChildren('button') buttons: QueryList<ElementRef>
|
||||||
|
|
||||||
private customFields: CustomField[] = []
|
private customFields: CustomField[] = []
|
||||||
private unusedFields: CustomField[] = []
|
private readonly unusedFields = signal<CustomField[]>([])
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
public get filteredFields(): CustomField[] {
|
public get filteredFields(): CustomField[] {
|
||||||
return this.unusedFields.filter(
|
return this.unusedFields().filter(
|
||||||
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,8 +100,10 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateUnusedFields() {
|
private updateUnusedFields() {
|
||||||
this.unusedFields = this.customFields.filter(
|
this.unusedFields.set(
|
||||||
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
this.customFields.filter(
|
||||||
|
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@
|
|||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select #fieldSelects
|
<ng-select #fieldSelects
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
[items]="customFields"
|
[items]="customFields()"
|
||||||
[(ngModel)]="atom.field"
|
[(ngModel)]="atom.field"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
|
|||||||
+3
-3
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize custom fields on creation', () => {
|
it('should initialize custom fields on creation', () => {
|
||||||
expect(component.customFields).toEqual(customFields)
|
expect(component.customFields()).toEqual(customFields)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add an expression when opened if queries are empty', () => {
|
it('should add an expression when opened if queries are empty', () => {
|
||||||
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
data_type: CustomFieldDataType.String,
|
data_type: CustomFieldDataType.String,
|
||||||
extra_data: {},
|
extra_data: {},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const operators = component.getOperatorsForField(1)
|
const operators = component.getOperatorsForField(1)
|
||||||
expect(operators.length).toEqual(
|
expect(operators.length).toEqual(
|
||||||
[
|
[
|
||||||
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const options = component.getSelectOptionsForField(1)
|
const options = component.getSelectOptionsForField(1)
|
||||||
expect(options).toEqual([
|
expect(options).toEqual([
|
||||||
{ label: 'Option 1', id: 'abc-123' },
|
{ label: 'Option 1', id: 'abc-123' },
|
||||||
|
|||||||
+6
-5
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -278,7 +279,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
@Output()
|
@Output()
|
||||||
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
customFields: CustomField[] = []
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||||
|
|
||||||
@@ -325,12 +326,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.customFields = result.results
|
this.customFields.set(result.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCustomFieldByID(id: number): CustomField {
|
public getCustomFieldByID(id: number): CustomField {
|
||||||
return this.customFields.find((field) => field.id === id)
|
return this.customFields().find((field) => field.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public addAtom(expression: CustomFieldQueryExpression) {
|
public addAtom(expression: CustomFieldQueryExpression) {
|
||||||
@@ -353,7 +354,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getOperatorsForField(
|
getOperatorsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ value: string; label: string }> {
|
): Array<{ value: string; label: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
const groups: CustomFieldQueryOperatorGroups[] = field
|
const groups: CustomFieldQueryOperatorGroups[] = field
|
||||||
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
||||||
: [CustomFieldQueryOperatorGroups.Basic]
|
: [CustomFieldQueryOperatorGroups.Basic]
|
||||||
@@ -369,7 +370,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getSelectOptionsForField(
|
getSelectOptionsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ label: string; id: string }> {
|
): Array<{ label: string; id: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
if (field) {
|
if (field) {
|
||||||
return field.extra_data['select_options']
|
return field.extra_data['select_options']
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -22,11 +22,11 @@
|
|||||||
<ng-template>
|
<ng-template>
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
@if (testLoading) {
|
@if (testLoading()) {
|
||||||
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
||||||
} @else if (testResult) {
|
} @else if (testResult()) {
|
||||||
<code>{{testResult}}</code>
|
<code>{{testResult()}}</code>
|
||||||
} @else if (testFailed) {
|
} @else if (testFailed()) {
|
||||||
<div class="text-danger" i18n>Path test failed</div>
|
<div class="text-danger" i18n>Path test failed</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="text-muted small" i18n>No document selected</div>
|
<div class="text-muted small" i18n>No document selected</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
[compareWith]="compareDocuments"
|
[compareWith]="compareDocuments"
|
||||||
[trackByFn]="trackByFn"
|
[trackByFn]="trackByFn"
|
||||||
[minTermLength]="2"
|
[minTermLength]="2"
|
||||||
[loading]="loading"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(change)="testPath($event)">
|
(change)="testPath($event)">
|
||||||
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
||||||
|
|||||||
+5
-5
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
||||||
expect(component.testResult).toBe('test/abc123')
|
expect(component.testResult()).toBe('test/abc123')
|
||||||
expect(component.testFailed).toBeFalsy()
|
expect(component.testFailed()).toBeFalsy()
|
||||||
|
|
||||||
// test failed
|
// test failed
|
||||||
testSpy.mockReturnValueOnce(of(''))
|
testSpy.mockReturnValueOnce(of(''))
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
expect(component.testFailed).toBeTruthy()
|
expect(component.testFailed()).toBeTruthy()
|
||||||
|
|
||||||
component.testPath(null)
|
component.testPath(null)
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should compare two documents by id', () => {
|
it('should compare two documents by id', () => {
|
||||||
|
|||||||
+11
-11
@@ -1,5 +1,5 @@
|
|||||||
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnDestroy, inject } from '@angular/core'
|
import { Component, OnDestroy, inject, signal } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
|
|||||||
public documentsInput$ = new Subject<string>()
|
public documentsInput$ = new Subject<string>()
|
||||||
public foundDocuments$: Observable<Document[]>
|
public foundDocuments$: Observable<Document[]>
|
||||||
private testDocument: Document
|
private testDocument: Document
|
||||||
public testResult: string
|
readonly testResult = signal<string>(undefined)
|
||||||
public testFailed: boolean = false
|
readonly testFailed = signal(false)
|
||||||
public testLoading = false
|
readonly testLoading = signal(false)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
|
|||||||
|
|
||||||
public testPath(document: Document) {
|
public testPath(document: Document) {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.testDocument = document
|
this.testDocument = document
|
||||||
this.testLoading = true
|
this.testLoading.set(true)
|
||||||
;(this.service as StoragePathService)
|
;(this.service as StoragePathService)
|
||||||
.testPath(this.objectForm.get('path').value, document.id)
|
.testPath(this.objectForm.get('path').value, document.id)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
if (result?.length) {
|
if (result?.length) {
|
||||||
this.testResult = result
|
this.testResult.set(result)
|
||||||
this.testFailed = false
|
this.testFailed.set(false)
|
||||||
} else {
|
} else {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
this.testFailed = true
|
this.testFailed.set(true)
|
||||||
}
|
}
|
||||||
this.testLoading = false
|
this.testLoading.set(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
||||||
|
|
||||||
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
||||||
|
|
||||||
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
||||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject, signal } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||||
tags: Tag[]
|
readonly tags = signal<Tag[]>([])
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
|||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
this.service.listAll().subscribe((result) => {
|
this.service.listAll().subscribe((result) => {
|
||||||
this.tags = result.results
|
this.tags.set(result.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-17
@@ -34,32 +34,30 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (selectionModel.items) {
|
@if (selectionModel.items) {
|
||||||
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
|
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
|
||||||
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
|
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index">
|
||||||
@if (allowSelectNone || item.id) {
|
<pngx-toggleable-dropdown-button
|
||||||
<pngx-toggleable-dropdown-button
|
[item]="item"
|
||||||
[item]="item"
|
[hideCount]="hideCount(item)"
|
||||||
[hideCount]="hideCount(item)"
|
[opacifyCount]="!editing"
|
||||||
[opacifyCount]="!editing"
|
[state]="selectionModel.get(item.id)"
|
||||||
[state]="selectionModel.get(item.id)"
|
[count]="getUpdatedDocumentCount(item.id)"
|
||||||
[count]="getUpdatedDocumentCount(item.id)"
|
(toggled)="selectionModel.toggle(item.id)"
|
||||||
(toggled)="selectionModel.toggle(item.id)"
|
(exclude)="excludeClicked(item.id)"
|
||||||
(exclude)="excludeClicked(item.id)"
|
[disabled]="disabled">
|
||||||
[disabled]="disabled">
|
</pngx-toggleable-dropdown-button>
|
||||||
</pngx-toggleable-dropdown-button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
@if (filteredItems.length === 0 && createRef !== undefined) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
@if (filteredItems.length > 0) {
|
||||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
|
||||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-3
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
selectionModel.toggle(items[0].id)
|
selectionModel.toggle(items[0].id)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.modelIsDirty).toBeTruthy()
|
expect(component.modelIsDirty()).toBeTruthy()
|
||||||
let applyResult: ChangedItems
|
let applyResult: ChangedItems
|
||||||
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
||||||
component.apply.subscribe((result) => (applyResult = result))
|
component.apply.subscribe((result) => (applyResult = result))
|
||||||
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
selectionModel.toggle(items[0].id)
|
selectionModel.toggle(items[0].id)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.modelIsDirty).toBeTruthy()
|
expect(component.modelIsDirty()).toBeTruthy()
|
||||||
let applyResult: ChangedItems
|
let applyResult: ChangedItems
|
||||||
component.apply.subscribe((result) => (applyResult = result))
|
component.apply.subscribe((result) => (applyResult = result))
|
||||||
component.dropdown.close()
|
component.dropdown.close()
|
||||||
@@ -265,7 +265,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(document.activeElement).toEqual(
|
expect(document.activeElement).toEqual(
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
)
|
)
|
||||||
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
|
expect(component.buttonsViewport.getRenderedRange().end).toEqual(
|
||||||
|
items.length
|
||||||
|
) // all selectable items shown
|
||||||
|
|
||||||
component.filterText = 'Tag2'
|
component.filterText = 'Tag2'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -278,6 +280,29 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(component.filterText).toHaveLength(0)
|
expect(component.filterText).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should omit disallowed null items from the virtual scroll viewport', async () => {
|
||||||
|
component.selectionModel.items = items
|
||||||
|
component.icon = 'tag-fill'
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('button')
|
||||||
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
|
fixture.detectChanges()
|
||||||
|
await wait(100)
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.buttonsViewport?.checkViewportSize()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.filteredItems).toEqual(items)
|
||||||
|
expect(component.scrollViewportHeight).toEqual(
|
||||||
|
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
|
||||||
|
'.cdk-virtual-scroll-content-wrapper > div'
|
||||||
|
)
|
||||||
|
).toHaveLength(items.length)
|
||||||
|
})
|
||||||
|
|
||||||
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
|
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
|
|||||||
+15
-10
@@ -12,6 +12,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -660,7 +661,6 @@ export class FilterableDropdownSelectionModel {
|
|||||||
imports: [
|
imports: [
|
||||||
ClearableBadgeComponent,
|
ClearableBadgeComponent,
|
||||||
ToggleableDropdownButtonComponent,
|
ToggleableDropdownButtonComponent,
|
||||||
FilterPipe,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
@@ -796,22 +796,27 @@ export class FilterableDropdownComponent
|
|||||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
modelIsDirty: boolean = false
|
readonly modelIsDirty = signal(false)
|
||||||
|
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
|
public get filteredItems(): MatchingModel[] {
|
||||||
|
return this.filterPipe
|
||||||
|
.transform(this.items, this.filterText, 'name')
|
||||||
|
.filter((item) => this.allowSelectNone || Boolean(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
public get scrollViewportHeight(): number {
|
public get scrollViewportHeight(): number {
|
||||||
const filteredLength = this.filterPipe.transform(
|
return Math.min(
|
||||||
this.items,
|
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX,
|
||||||
this.filterText
|
400
|
||||||
).length
|
)
|
||||||
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.selectionModelChange.subscribe((updatedModel) => {
|
this.selectionModelChange.subscribe((updatedModel) => {
|
||||||
this.modelIsDirty = updatedModel.isDirty()
|
this.modelIsDirty.set(updatedModel.isDirty())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,7 +863,7 @@ export class FilterableDropdownComponent
|
|||||||
}, 0)
|
}, 0)
|
||||||
if (this.editing) {
|
if (this.editing) {
|
||||||
this.selectionModel.reset()
|
this.selectionModel.reset()
|
||||||
this.modelIsDirty = false
|
this.modelIsDirty.set(false)
|
||||||
}
|
}
|
||||||
this.selectionModel.singleSelect =
|
this.selectionModel.singleSelect =
|
||||||
this.editing && !this.selectionModel.manyToOne
|
this.editing && !this.selectionModel.manyToOne
|
||||||
@@ -877,7 +882,7 @@ export class FilterableDropdownComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
listFilterEnter(): void {
|
listFilterEnter(): void {
|
||||||
let filtered = this.filterPipe.transform(this.items, this.filterText)
|
const filtered = this.filteredItems
|
||||||
if (filtered.length == 1) {
|
if (filtered.length == 1) {
|
||||||
this.selectionModel.toggle(filtered[0].id)
|
this.selectionModel.toggle(filtered[0].id)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
+4
-3
@@ -5,6 +5,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -63,11 +64,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
|
|
||||||
super()
|
super()
|
||||||
customFieldsService.listAll().subscribe((items) => {
|
customFieldsService.listAll().subscribe((items) => {
|
||||||
this.fields = items.results
|
this.fields.set(items.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fields: CustomField[]
|
private readonly fields = signal<CustomField[]>([])
|
||||||
|
|
||||||
private _selectedFields: number[]
|
private _selectedFields: number[]
|
||||||
|
|
||||||
@@ -90,6 +91,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
|
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
|
||||||
|
|
||||||
public getCustomField(id: number): CustomField {
|
public getCustomField(id: number): CustomField {
|
||||||
return this.fields.find((field) => field.id === id)
|
return this.fields().find((field) => field.id === id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
[compareWith]="compareDocuments"
|
[compareWith]="compareDocuments"
|
||||||
[trackByFn]="trackByFn"
|
[trackByFn]="trackByFn"
|
||||||
[minTermLength]="2"
|
[minTermLength]="2"
|
||||||
[loading]="loading"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
(change)="onChange(selectedDocumentIDs)">
|
(change)="onChange(selectedDocumentIDs)">
|
||||||
|
|||||||
+26
-2
@@ -2,7 +2,11 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { of, throwError } from 'rxjs'
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { provideRouter } from '@angular/router'
|
||||||
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, Subject, throwError } from 'rxjs'
|
||||||
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
|
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { DocumentLinkComponent } from './document-link.component'
|
import { DocumentLinkComponent } from './document-link.component'
|
||||||
@@ -33,10 +37,11 @@ describe('DocumentLinkComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [DocumentLinkComponent],
|
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
@@ -60,6 +65,25 @@ describe('DocumentLinkComponent', () => {
|
|||||||
expect(getSpy).toHaveBeenCalled()
|
expect(getSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render loading and selected documents after async updates', async () => {
|
||||||
|
const result$ = new Subject<any>()
|
||||||
|
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
|
||||||
|
|
||||||
|
component.writeValue([1])
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
|
||||||
|
.componentInstance as NgSelectComponent
|
||||||
|
expect(select.loading()).toBe(true)
|
||||||
|
|
||||||
|
result$.next({ count: 1, all: [1], results: [documents[0]] })
|
||||||
|
result$.complete()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(select.loading()).toBe(false)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
it('shoud maintain ordering of selected documents', () => {
|
it('shoud maintain ordering of selected documents', () => {
|
||||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||||
getSpy.mockImplementation((ids) => {
|
getSpy.mockImplementation((ids) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -63,7 +64,7 @@ export class DocumentLinkComponent
|
|||||||
|
|
||||||
documentsInput$ = new Subject<string>()
|
documentsInput$ = new Subject<string>()
|
||||||
foundDocuments$: Observable<Document[]>
|
foundDocuments$: Observable<Document[]>
|
||||||
loading = false
|
readonly loading = signal(false)
|
||||||
selectedDocuments: Document[] = []
|
selectedDocuments: Document[] = []
|
||||||
|
|
||||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -93,12 +94,12 @@ export class DocumentLinkComponent
|
|||||||
this.selectedDocuments = []
|
this.selectedDocuments = []
|
||||||
super.writeValue([])
|
super.writeValue([])
|
||||||
} else {
|
} else {
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.getFew(documentIDs, { fields: 'id,title' })
|
.getFew(documentIDs, { fields: 'id,title' })
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((documentResults) => {
|
.subscribe((documentResults) => {
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
this.selectedDocuments = documentIDs.map(
|
this.selectedDocuments = documentIDs.map(
|
||||||
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||||
)
|
)
|
||||||
@@ -113,7 +114,7 @@ export class DocumentLinkComponent
|
|||||||
this.documentsInput$.pipe(
|
this.documentsInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap(() => (this.loading = true)),
|
tap(() => this.loading.set(true)),
|
||||||
switchMap((title) =>
|
switchMap((title) =>
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -133,7 +134,7 @@ export class DocumentLinkComponent
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
catchError(() => of([])), // empty on error
|
catchError(() => of([])), // empty on error
|
||||||
tap(() => (this.loading = false))
|
tap(() => this.loading.set(false))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
|||||||
this.documentService.getNextAsn().subscribe((nextAsn) => {
|
this.documentService.getNextAsn().subscribe((nextAsn) => {
|
||||||
this.value = nextAsn
|
this.value = nextAsn
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,18 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
expect(applyScaleSpy).toHaveBeenCalled()
|
expect(applyScaleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not reset the viewer when it is already on the requested page', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const viewer = (component as any).pdfViewer as PDFViewer
|
||||||
|
const currentPageSpy = jest.spyOn(viewer, 'currentPageNumber', 'set')
|
||||||
|
component.page = viewer.currentPageNumber
|
||||||
|
|
||||||
|
;(component as any).applyViewerState()
|
||||||
|
|
||||||
|
expect(currentPageSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('dispatches find when search query changes after render', async () => {
|
it('dispatches find when search query changes after render', async () => {
|
||||||
await initComponent()
|
await initComponent()
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,9 @@ export class PngxPdfViewerComponent
|
|||||||
Math.max(Math.trunc(this.page), 1),
|
Math.max(Math.trunc(this.page), 1),
|
||||||
this.pdfViewer.pagesCount
|
this.pdfViewer.pagesCount
|
||||||
)
|
)
|
||||||
this.pdfViewer.currentPageNumber = nextPage
|
if (nextPage !== this.pdfViewer.currentPageNumber) {
|
||||||
|
this.pdfViewer.currentPageNumber = nextPage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.page === this.lastViewerPage) {
|
if (this.page === this.lastViewerPage) {
|
||||||
this.lastViewerPage = undefined
|
this.lastViewerPage = undefined
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@
|
|||||||
[(ngModel)]="selectionModel.includeUsers"
|
[(ngModel)]="selectionModel.includeUsers"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[clearable]="false"
|
[clearable]="false"
|
||||||
[items]="users"
|
[items]="users()"
|
||||||
bindLabel="username"
|
bindLabel="username"
|
||||||
multiple="true"
|
multiple="true"
|
||||||
bindValue="id"
|
bindValue="id"
|
||||||
|
|||||||
+10
-3
@@ -1,5 +1,12 @@
|
|||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
@@ -75,7 +82,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
@Output()
|
@Output()
|
||||||
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
|
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
|
||||||
|
|
||||||
users: User[]
|
readonly users = signal<User[]>([])
|
||||||
|
|
||||||
hideUnowned: boolean
|
hideUnowned: boolean
|
||||||
|
|
||||||
@@ -102,7 +109,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => (this.users = result.results),
|
next: (result) => this.users.set(result.results),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -57,15 +57,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
@if (socialAccounts?.length > 0) {
|
@if (socialAccounts().length > 0) {
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p i18n>Connected social accounts</p>
|
<p i18n>Connected social accounts</p>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
@for (account of socialAccounts; track account.id) {
|
@for (account of socialAccounts(); track account.id) {
|
||||||
<li class="list-group-item"
|
<li class="list-group-item"
|
||||||
ngbPopover="Set a password before disconnecting social account."
|
ngbPopover="Set a password before disconnecting social account."
|
||||||
i18n-ngbPopover
|
i18n-ngbPopover
|
||||||
[disablePopover]="hasUsablePassword"
|
[disablePopover]="hasUsablePassword()"
|
||||||
triggers="mouseenter:mouseleave">
|
triggers="mouseenter:mouseleave">
|
||||||
{{account.name}} ({{account.provider}})
|
{{account.name}} ({{account.provider}})
|
||||||
<pngx-confirm-button
|
<pngx-confirm-button
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||||
iconName="trash"
|
iconName="trash"
|
||||||
[disabled]="!hasUsablePassword"
|
[disabled]="!hasUsablePassword()"
|
||||||
(confirm)="disconnectSocialAccount(account.id)">
|
(confirm)="disconnectSocialAccount(account.id)">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
</li>
|
</li>
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (socialAccountProviders?.length > 0) {
|
@if (socialAccountProviders().length > 0) {
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p i18n>Connect new social account</p>
|
<p i18n>Connect new social account</p>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
@for (provider of socialAccountProviders; track provider.name) {
|
@for (provider of socialAccountProviders(); track provider.name) {
|
||||||
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||||
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
|
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!isTotpEnabled) {
|
@if (!isTotpEnabled()) {
|
||||||
<div ngbAccordion>
|
<div ngbAccordion>
|
||||||
<div ngbAccordionItem>
|
<div ngbAccordionItem>
|
||||||
<h2 ngbAccordionHeader>
|
<h2 ngbAccordionHeader>
|
||||||
@@ -105,10 +105,10 @@
|
|||||||
<div ngbAccordionCollapse>
|
<div ngbAccordionCollapse>
|
||||||
<div ngbAccordionBody>
|
<div ngbAccordionBody>
|
||||||
<ng-template>
|
<ng-template>
|
||||||
@if (totpSettingsLoading) {
|
@if (totpSettingsLoading()) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
} @else if (totpSettings) {
|
} @else if (totpSettings()) {
|
||||||
<figure class="figure">
|
<figure class="figure">
|
||||||
@if (qrSvgDataUrl) {
|
@if (qrSvgDataUrl) {
|
||||||
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
|
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
|
||||||
@@ -116,14 +116,14 @@
|
|||||||
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<p>
|
<p>
|
||||||
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
|
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>.
|
||||||
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
||||||
</p>
|
</p>
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
||||||
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
|
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()">
|
||||||
<ng-container i18n>Enable</ng-container>
|
<ng-container i18n>Enable</ng-container>
|
||||||
@if (totpLoading) {
|
@if (totpLoading()) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
}
|
}
|
||||||
@@ -137,18 +137,18 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
||||||
@if (recoveryCodes) {
|
@if (recoveryCodes()) {
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-row align-items-start mb-3">
|
<div class="d-flex flex-row align-items-start mb-3">
|
||||||
<ul class="list-group w-50">
|
<ul class="list-group w-50">
|
||||||
@for (code of recoveryCodes; track code; let i = $index) {
|
@for (code of recoveryCodes(); track code; let i = $index) {
|
||||||
@if (i % 2 === 0) {
|
@if (i % 2 === 0) {
|
||||||
<li class="list-group-item d-flex justify-content-around align-items-center">
|
<li class="list-group-item d-flex justify-content-around align-items-center">
|
||||||
<code>{{code}}</code>
|
<code>{{code}}</code>
|
||||||
@if (recoveryCodes[i + 1]) {
|
@if (recoveryCodes()[i + 1]) {
|
||||||
<code>{{recoveryCodes[i + 1]}}</code>
|
<code>{{recoveryCodes()[i + 1]}}</code>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
buttonClasses="btn-outline-danger btn-sm"
|
buttonClasses="btn-outline-danger btn-sm"
|
||||||
iconName="trash"
|
iconName="trash"
|
||||||
[disabled]="totpLoading"
|
[disabled]="totpLoading()"
|
||||||
(confirm)="deactivateTotp()">
|
(confirm)="deactivateTotp()">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-12
@@ -10,7 +10,7 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { NEVER, of, Subject, throwError } from 'rxjs'
|
||||||
import { ProfileService } from 'src/app/services/profile.service'
|
import { ProfileService } from 'src/app/services/profile.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import * as navUtils from 'src/app/utils/navigation'
|
import * as navUtils from 'src/app/utils/navigation'
|
||||||
@@ -60,6 +60,10 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
|
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
|
||||||
})
|
})
|
||||||
profileService = TestBed.inject(ProfileService)
|
profileService = TestBed.inject(ProfileService)
|
||||||
|
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
|
||||||
|
jest
|
||||||
|
.spyOn(profileService, 'getSocialAccountProviders')
|
||||||
|
.mockReturnValue(of([]))
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
clipboard = TestBed.inject(Clipboard)
|
clipboard = TestBed.inject(Clipboard)
|
||||||
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
||||||
@@ -155,7 +159,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
'getSocialAccountProviders'
|
'getSocialAccountProviders'
|
||||||
)
|
)
|
||||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
component.hasUsablePassword = true
|
component.hasUsablePassword.set(true)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.form.get('password').patchValue('new*pass')
|
component.form.get('password').patchValue('new*pass')
|
||||||
component.onPasswordKeyUp({
|
component.onPasswordKeyUp({
|
||||||
@@ -268,6 +272,27 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(getProvidersSpy).toHaveBeenCalled()
|
expect(getProvidersSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render social account providers after an async update', async () => {
|
||||||
|
const providers$ = new Subject<typeof socialAccountProviders>()
|
||||||
|
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
|
||||||
|
jest
|
||||||
|
.spyOn(profileService, 'getSocialAccountProviders')
|
||||||
|
.mockReturnValue(providers$)
|
||||||
|
|
||||||
|
component.ngOnInit()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
providers$.next(socialAccountProviders)
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should remove disconnected social account from component, show error if needed', () => {
|
it('should remove disconnected social account from component, show error if needed', () => {
|
||||||
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
@@ -276,7 +301,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
|
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
expect(component.socialAccounts).toContainEqual(socialAccount)
|
expect(component.socialAccounts()).toContainEqual(socialAccount)
|
||||||
|
|
||||||
// fail first
|
// fail first
|
||||||
disconnectSpy.mockReturnValueOnce(
|
disconnectSpy.mockReturnValueOnce(
|
||||||
@@ -289,7 +314,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
||||||
component.disconnectSocialAccount(socialAccount.id)
|
component.disconnectSocialAccount(socialAccount.id)
|
||||||
expect(disconnectSpy).toHaveBeenCalled()
|
expect(disconnectSpy).toHaveBeenCalled()
|
||||||
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
expect(component.socialAccounts()).not.toContainEqual(socialAccount)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get totp settings', () => {
|
it('should get totp settings', () => {
|
||||||
@@ -310,7 +335,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
getSpy.mockReturnValue(of(settings))
|
getSpy.mockReturnValue(of(settings))
|
||||||
component.gettotpSettings()
|
component.gettotpSettings()
|
||||||
expect(getSpy).toHaveBeenCalled()
|
expect(getSpy).toHaveBeenCalled()
|
||||||
expect(component.totpSettings).toEqual(settings)
|
expect(component.totpSettings()).toEqual(settings)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should activate totp', () => {
|
it('should activate totp', () => {
|
||||||
@@ -319,15 +344,15 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const error = new Error('failed to activate totp')
|
const error = new Error('failed to activate totp')
|
||||||
activateSpy.mockReturnValueOnce(throwError(() => error))
|
activateSpy.mockReturnValueOnce(throwError(() => error))
|
||||||
component.totpSettings = {
|
component.totpSettings.set({
|
||||||
url: 'http://localhost/',
|
url: 'http://localhost/',
|
||||||
qr_svg: 'svg',
|
qr_svg: 'svg',
|
||||||
secret: 'secret',
|
secret: 'secret',
|
||||||
}
|
})
|
||||||
component.form.get('totp_code').patchValue('123456')
|
component.form.get('totp_code').patchValue('123456')
|
||||||
component.activateTotp()
|
component.activateTotp()
|
||||||
expect(activateSpy).toHaveBeenCalledWith(
|
expect(activateSpy).toHaveBeenCalledWith(
|
||||||
component.totpSettings.secret,
|
component.totpSettings().secret,
|
||||||
component.form.get('totp_code').value
|
component.form.get('totp_code').value
|
||||||
)
|
)
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
@@ -341,8 +366,8 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
component.activateTotp()
|
component.activateTotp()
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(component.isTotpEnabled).toBeTruthy()
|
expect(component.isTotpEnabled()).toBeTruthy()
|
||||||
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
|
expect(component.recoveryCodes()).toEqual(['1', '2', '3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should deactivate totp', () => {
|
it('should deactivate totp', () => {
|
||||||
@@ -362,13 +387,13 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
deactivateSpy.mockReturnValueOnce(of(true))
|
deactivateSpy.mockReturnValueOnce(of(true))
|
||||||
component.deactivateTotp()
|
component.deactivateTotp()
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(component.isTotpEnabled).toBeFalsy()
|
expect(component.isTotpEnabled()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should copy recovery codes', () => {
|
it('should copy recovery codes', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
component.recoveryCodes = ['1', '2', '3']
|
component.recoveryCodes.set(['1', '2', '3'])
|
||||||
component.copyRecoveryCodes()
|
component.copyRecoveryCodes()
|
||||||
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
||||||
jest.advanceTimersByTime(3000)
|
jest.advanceTimersByTime(3000)
|
||||||
|
|||||||
+36
-33
@@ -56,6 +56,14 @@ export class ProfileEditDialogComponent
|
|||||||
readonly showEmailConfirm = signal(false)
|
readonly showEmailConfirm = signal(false)
|
||||||
readonly copied = signal(false)
|
readonly copied = signal(false)
|
||||||
readonly codesCopied = signal(false)
|
readonly codesCopied = signal(false)
|
||||||
|
readonly hasUsablePassword = signal(false)
|
||||||
|
readonly isTotpEnabled = signal(false)
|
||||||
|
readonly totpSettings = signal<TotpSettings>(undefined)
|
||||||
|
readonly totpSettingsLoading = signal(false)
|
||||||
|
readonly totpLoading = signal(false)
|
||||||
|
readonly recoveryCodes = signal<string[]>(undefined)
|
||||||
|
readonly socialAccounts = signal<SocialAccount[]>([])
|
||||||
|
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
|
||||||
|
|
||||||
public form = new FormGroup({
|
public form = new FormGroup({
|
||||||
email: new FormControl(''),
|
email: new FormControl(''),
|
||||||
@@ -72,25 +80,15 @@ export class ProfileEditDialogComponent
|
|||||||
private newPassword: string
|
private newPassword: string
|
||||||
private passwordConfirm: string
|
private passwordConfirm: string
|
||||||
|
|
||||||
public hasUsablePassword: boolean = false
|
|
||||||
|
|
||||||
private currentEmail: string
|
private currentEmail: string
|
||||||
private newEmail: string
|
private newEmail: string
|
||||||
private emailConfirm: string
|
private emailConfirm: string
|
||||||
|
|
||||||
public isTotpEnabled: boolean = false
|
|
||||||
public totpSettings: TotpSettings
|
|
||||||
public totpSettingsLoading: boolean = false
|
|
||||||
public totpLoading: boolean = false
|
|
||||||
public recoveryCodes: string[]
|
|
||||||
public socialAccounts: SocialAccount[] = []
|
|
||||||
public socialAccountProviders: SocialAccountProvider[] = []
|
|
||||||
|
|
||||||
get qrSvgDataUrl(): string | null {
|
get qrSvgDataUrl(): string | null {
|
||||||
if (!this.totpSettings?.qr_svg) {
|
if (!this.totpSettings()?.qr_svg) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
|
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -107,20 +105,20 @@ export class ProfileEditDialogComponent
|
|||||||
this.onEmailChange()
|
this.onEmailChange()
|
||||||
})
|
})
|
||||||
this.currentPassword = profile.password
|
this.currentPassword = profile.password
|
||||||
this.hasUsablePassword = profile.has_usable_password
|
this.hasUsablePassword.set(profile.has_usable_password)
|
||||||
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
||||||
this.newPassword = newPassword
|
this.newPassword = newPassword
|
||||||
this.onPasswordChange()
|
this.onPasswordChange()
|
||||||
})
|
})
|
||||||
this.socialAccounts = profile.social_accounts
|
this.socialAccounts.set(profile.social_accounts ?? [])
|
||||||
this.isTotpEnabled = profile.is_mfa_enabled
|
this.isTotpEnabled.set(profile.is_mfa_enabled)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.profileService
|
this.profileService
|
||||||
.getSocialAccountProviders()
|
.getSocialAccountProviders()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((providers) => {
|
.subscribe((providers) => {
|
||||||
this.socialAccountProviders = providers
|
this.socialAccountProviders.set(providers ?? [])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +257,9 @@ export class ProfileEditDialogComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (id: number) => {
|
next: (id: number) => {
|
||||||
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
|
this.socialAccounts.update((accounts) =>
|
||||||
|
accounts.filter((account) => account.id != id)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -271,36 +271,39 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public gettotpSettings(): void {
|
public gettotpSettings(): void {
|
||||||
this.totpSettingsLoading = true
|
this.totpSettingsLoading.set(true)
|
||||||
this.profileService
|
this.profileService
|
||||||
.getTotpSettings()
|
.getTotpSettings()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (totpSettings) => {
|
next: (totpSettings) => {
|
||||||
this.totpSettingsLoading = false
|
this.totpSettingsLoading.set(false)
|
||||||
this.totpSettings = totpSettings
|
this.totpSettings.set(totpSettings)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error fetching TOTP settings`,
|
$localize`Error fetching TOTP settings`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
this.totpSettingsLoading = false
|
this.totpSettingsLoading.set(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public activateTotp(): void {
|
public activateTotp(): void {
|
||||||
this.totpLoading = true
|
this.totpLoading.set(true)
|
||||||
this.form.get('totp_code').disable()
|
this.form.get('totp_code').disable()
|
||||||
this.profileService
|
this.profileService
|
||||||
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
|
.activateTotp(
|
||||||
|
this.totpSettings().secret,
|
||||||
|
this.form.get('totp_code').value
|
||||||
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (activationResponse) => {
|
next: (activationResponse) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.isTotpEnabled = activationResponse.success
|
this.isTotpEnabled.set(activationResponse.success)
|
||||||
this.recoveryCodes = activationResponse.recovery_codes
|
this.recoveryCodes.set(activationResponse.recovery_codes)
|
||||||
this.form.get('totp_code').enable()
|
this.form.get('totp_code').enable()
|
||||||
if (activationResponse.success) {
|
if (activationResponse.success) {
|
||||||
this.toastService.showInfo($localize`TOTP activated successfully`)
|
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||||
@@ -309,7 +312,7 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.form.get('totp_code').enable()
|
this.form.get('totp_code').enable()
|
||||||
this.toastService.showError($localize`Error activating TOTP`, error)
|
this.toastService.showError($localize`Error activating TOTP`, error)
|
||||||
},
|
},
|
||||||
@@ -317,15 +320,15 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public deactivateTotp(): void {
|
public deactivateTotp(): void {
|
||||||
this.totpLoading = true
|
this.totpLoading.set(true)
|
||||||
this.profileService
|
this.profileService
|
||||||
.deactivateTotp()
|
.deactivateTotp()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (success) => {
|
next: (success) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.isTotpEnabled = !success
|
this.isTotpEnabled.set(!success)
|
||||||
this.recoveryCodes = null
|
this.recoveryCodes.set(null)
|
||||||
if (success) {
|
if (success) {
|
||||||
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||||
} else {
|
} else {
|
||||||
@@ -333,14 +336,14 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public copyRecoveryCodes(): void {
|
public copyRecoveryCodes(): void {
|
||||||
this.clipboard.copy(this.recoveryCodes.join('\n'))
|
this.clipboard.copy(this.recoveryCodes().join('\n'))
|
||||||
this.codesCopied.set(true)
|
this.codesCopied.set(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.codesCopied.set(false)
|
this.codesCopied.set(false)
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div content class="wrapper fade" [class.show]="show()">
|
<div content class="wrapper fade" [class.show]="show()">
|
||||||
@if (displayMode() === DisplayMode.TABLE) {
|
@if (error()) {
|
||||||
|
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
|
||||||
|
} @else if (displayMode() === DisplayMode.TABLE) {
|
||||||
<table class="table table-hover mb-0 mt-n2 align-middle">
|
<table class="table table-hover mb-0 mt-n2 align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
+27
-2
@@ -1,6 +1,10 @@
|
|||||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import {
|
||||||
|
HttpErrorResponse,
|
||||||
|
provideHttpClient,
|
||||||
|
withInterceptorsFromDi,
|
||||||
|
} from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
@@ -8,7 +12,7 @@ import { Router } from '@angular/router'
|
|||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
||||||
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
||||||
@@ -230,6 +234,27 @@ describe('SavedViewWidgetComponent', () => {
|
|||||||
expect(component.documents()).toEqual(documentResults)
|
expect(component.documents()).toEqual(documentResults)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show an error if documents fail to load', () => {
|
||||||
|
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
error: { added__date__lte: ['Enter a valid date.'] },
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
component.reload()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(component.error()).toEqual('Added: Enter a valid date.')
|
||||||
|
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||||
|
'Error while loading documents: Added: Enter a valid date.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should reload on document consumption finished', () => {
|
it('should reload on document consumption finished', () => {
|
||||||
const fileStatusSubject = new Subject<FileStatus>()
|
const fileStatusSubject = new Subject<FileStatus>()
|
||||||
jest
|
jest
|
||||||
|
|||||||
+37
-6
@@ -125,6 +125,8 @@ export class SavedViewWidgetComponent
|
|||||||
|
|
||||||
readonly count = signal<number>(null)
|
readonly count = signal<number>(null)
|
||||||
|
|
||||||
|
readonly error = signal<string | null>(null)
|
||||||
|
|
||||||
placeholderRows: number[] = []
|
placeholderRows: number[] = []
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -180,6 +182,7 @@ export class SavedViewWidgetComponent
|
|||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
this.loading.set(this.documents().length == 0)
|
this.loading.set(this.documents().length == 0)
|
||||||
|
this.error.set(null)
|
||||||
this.show.set(true)
|
this.show.set(true)
|
||||||
this.documentService
|
this.documentService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -191,12 +194,40 @@ export class SavedViewWidgetComponent
|
|||||||
{ truncate_content: true }
|
{ truncate_content: true }
|
||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe({
|
||||||
this.documents.set(result.results)
|
next: (result) => {
|
||||||
this.count.set(result.count)
|
this.documents.set(result.results)
|
||||||
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
this.count.set(result.count)
|
||||||
this.loading.set(false)
|
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
||||||
this.show.set(true)
|
this.loading.set(false)
|
||||||
|
this.show.set(true)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.documents.set([])
|
||||||
|
this.count.set(null)
|
||||||
|
let errorMessage
|
||||||
|
if (
|
||||||
|
typeof error.error === 'object' &&
|
||||||
|
Object.keys(error.error).length > 0
|
||||||
|
) {
|
||||||
|
errorMessage = Object.keys(error.error)
|
||||||
|
.map((fieldName) => {
|
||||||
|
const fieldNameBase = fieldName.split('__')[0]
|
||||||
|
const fieldError: Array<string> = error.error[fieldName]
|
||||||
|
return `${
|
||||||
|
this.documentService.sortFields.find(
|
||||||
|
(f) => f.field?.split('__')[0] == fieldNameBase
|
||||||
|
)?.name ?? fieldNameBase
|
||||||
|
}: ${fieldError[0]}`
|
||||||
|
})
|
||||||
|
.join(', ')
|
||||||
|
} else {
|
||||||
|
errorMessage = error.error
|
||||||
|
}
|
||||||
|
this.error.set(errorMessage)
|
||||||
|
this.loading.set(false)
|
||||||
|
this.show.set(true)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<p i18n>Searching document with asn {{asn}}</p>
|
<p i18n>Searching document with asn {{asn()}}</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { FILTER_ASN } from '../../data/filter-rule-type'
|
import { FILTER_ASN } from '../../data/filter-rule-type'
|
||||||
import { DocumentService } from '../../services/rest/document.service'
|
import { DocumentService } from '../../services/rest/document.service'
|
||||||
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
|
||||||
asn: string
|
readonly asn = signal<string>(undefined)
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.route.paramMap.subscribe((paramMap) => {
|
this.route.paramMap.subscribe((paramMap) => {
|
||||||
this.asn = paramMap.get('id')
|
this.asn.set(paramMap.get('id'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
|
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }])
|
||||||
.subscribe((documentId) => {
|
.subscribe((documentId) => {
|
||||||
if (documentId.length == 1) {
|
if (documentId.length == 1) {
|
||||||
this.router.navigate(['documents', documentId[0]])
|
this.router.navigate(['documents', documentId[0]])
|
||||||
|
|||||||
@@ -482,9 +482,7 @@
|
|||||||
</pngx-pdf-viewer>
|
</pngx-pdf-viewer>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
|
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
<span>Preview is unavailable.</span>
|
|
||||||
</object>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Text) {
|
@case (ContentRenderType.Text) {
|
||||||
@@ -505,9 +503,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Other) {
|
@case (ContentRenderType.Other) {
|
||||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
|
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
<span>Preview is unavailable.</span>
|
|
||||||
</object>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (requiresPassword) {
|
@if (requiresPassword) {
|
||||||
|
|||||||
+6
-6
@@ -5,7 +5,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
<div class="px-3 py-2 mb-2">
|
<div class="px-3 py-2 mb-2">
|
||||||
@if (versionUploadState === UploadState.Idle) {
|
@if (versionUploadState() === UploadState.Idle) {
|
||||||
<div class="input-group input-group-sm mb-2">
|
<div class="input-group input-group-sm mb-2">
|
||||||
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
|
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
|
||||||
<input
|
<input
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
@switch (versionUploadState) {
|
@switch (versionUploadState()) {
|
||||||
@case (UploadState.Uploading) {
|
@case (UploadState.Uploading) {
|
||||||
<div class="small text-muted mt-1 d-flex align-items-center">
|
<div class="small text-muted mt-1 d-flex align-items-center">
|
||||||
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
|
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
<span i18n>Version upload failed.</span>
|
<span i18n>Version upload failed.</span>
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
|
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
@if (versionUploadError) {
|
@if (versionUploadError()) {
|
||||||
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
|
<div class="small text-muted mt-1">{{ versionUploadError() }}</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
[(ngModel)]="versionLabelDraft"
|
[(ngModel)]="versionLabelDraft"
|
||||||
i18n-placeholder
|
i18n-placeholder
|
||||||
placeholder="Version label"
|
placeholder="Version label"
|
||||||
[disabled]="savingVersionLabelId !== null"
|
[disabled]="savingVersionLabelId() !== null"
|
||||||
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
||||||
(keydown.escape)="cancelEditingVersion($event)"
|
(keydown.escape)="cancelEditingVersion($event)"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
[disabled]="savingVersionLabelId !== null"
|
[disabled]="savingVersionLabelId() !== null"
|
||||||
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
||||||
>
|
>
|
||||||
@if (isEditingVersion(version.id)) {
|
@if (isEditingVersion(version.id)) {
|
||||||
|
|||||||
+17
-15
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
||||||
])
|
])
|
||||||
expect(component.savingVersionLabelId).toBeNull()
|
expect(component.savingVersionLabelId()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saveVersionLabel should show error toast on failure', () => {
|
it('saveVersionLabel should show error toast on failure', () => {
|
||||||
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
'Error updating version label',
|
'Error updating version label',
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
expect(component.savingVersionLabelId).toBeNull()
|
expect(component.savingVersionLabelId()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||||
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||||
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
||||||
expect(component.newVersionLabel).toEqual('')
|
expect(component.newVersionLabel).toEqual('')
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||||
expect(component.versionUploadError).toBeNull()
|
expect(component.versionUploadError()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should set failed state after websocket failure', () => {
|
it('onVersionFileSelected should render failed state after websocket failure', async () => {
|
||||||
const file = new File(['test'], 'new-version.pdf', {
|
const file = new File(['test'], 'new-version.pdf', {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
})
|
})
|
||||||
@@ -266,9 +266,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
failed$.next({ taskId: 'task-1', message: 'processing failed' })
|
failed$.next({ taskId: 'task-1', message: 'processing failed' })
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('processing failed')
|
expect(component.versionUploadError()).toEqual('processing failed')
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('processing failed')
|
||||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,8 +284,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('Missing task ID.')
|
expect(component.versionUploadError()).toEqual('Missing task ID.')
|
||||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,8 +300,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('upload failed')
|
expect(component.versionUploadError()).toEqual('upload failed')
|
||||||
expect(toastService.showError).toHaveBeenCalledWith(
|
expect(toastService.showError).toHaveBeenCalledWith(
|
||||||
'Error uploading new version',
|
'Error uploading new version',
|
||||||
error
|
error
|
||||||
@@ -307,8 +309,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ngOnChanges should clear upload status on document switch', () => {
|
it('ngOnChanges should clear upload status on document switch', () => {
|
||||||
component.versionUploadState = UploadState.Failed
|
component.versionUploadState.set(UploadState.Failed)
|
||||||
component.versionUploadError = 'something failed'
|
component.versionUploadError.set('something failed')
|
||||||
component.editingVersionId = 10
|
component.editingVersionId = 10
|
||||||
component.versionLabelDraft = 'draft'
|
component.versionLabelDraft = 'draft'
|
||||||
|
|
||||||
@@ -316,8 +318,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
documentId: new SimpleChange(3, 4, false),
|
documentId: new SimpleChange(3, 4, false),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||||
expect(component.versionUploadError).toBeNull()
|
expect(component.versionUploadError()).toBeNull()
|
||||||
expect(component.editingVersionId).toBeNull()
|
expect(component.editingVersionId).toBeNull()
|
||||||
expect(component.versionLabelDraft).toEqual('')
|
expect(component.versionLabelDraft).toEqual('')
|
||||||
})
|
})
|
||||||
|
|||||||
+24
-20
@@ -7,6 +7,7 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
Output,
|
Output,
|
||||||
|
signal,
|
||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
@@ -59,9 +60,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
|
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
|
||||||
|
|
||||||
newVersionLabel: string = ''
|
newVersionLabel: string = ''
|
||||||
versionUploadState: UploadState = UploadState.Idle
|
readonly versionUploadState = signal(UploadState.Idle)
|
||||||
versionUploadError: string | null = null
|
readonly versionUploadError = signal<string | null>(null)
|
||||||
savingVersionLabelId: number | null = null
|
readonly savingVersionLabelId = signal<number | null>(null)
|
||||||
editingVersionId: number | null = null
|
editingVersionId: number | null = null
|
||||||
versionLabelDraft: string = ''
|
versionLabelDraft: string = ''
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
|
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return
|
||||||
this.editingVersionId = version.id
|
this.editingVersionId = version.id
|
||||||
this.versionLabelDraft = version.version_label ?? ''
|
this.versionLabelDraft = version.version_label ?? ''
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
if (this.savingVersionLabelId !== null) return
|
if (this.savingVersionLabelId() !== null) return
|
||||||
const nextLabel = this.versionLabelDraft?.trim() || null
|
const nextLabel = this.versionLabelDraft?.trim() || null
|
||||||
const currentLabel = version.version_label?.trim() || null
|
const currentLabel = version.version_label?.trim() || null
|
||||||
if (nextLabel === currentLabel) {
|
if (nextLabel === currentLabel) {
|
||||||
@@ -158,15 +159,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
||||||
if (this.savingVersionLabelId !== null) return
|
if (this.savingVersionLabelId() !== null) return
|
||||||
this.savingVersionLabelId = versionId
|
this.savingVersionLabelId.set(versionId)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (this.savingVersionLabelId === versionId) {
|
if (this.savingVersionLabelId() === versionId) {
|
||||||
this.savingVersionLabelId = null
|
this.savingVersionLabelId.set(null)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
@@ -199,8 +200,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
const file = input.files[0]
|
const file = input.files[0]
|
||||||
input.value = ''
|
input.value = ''
|
||||||
const label = this.newVersionLabel?.trim()
|
const label = this.newVersionLabel?.trim()
|
||||||
this.versionUploadState = UploadState.Uploading
|
this.versionUploadState.set(UploadState.Uploading)
|
||||||
this.versionUploadError = null
|
this.versionUploadError.set(null)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.uploadVersion(uploadDocumentId, file, label)
|
.uploadVersion(uploadDocumentId, file, label)
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -210,7 +211,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
$localize`Uploading new version. Processing will happen in the background.`
|
$localize`Uploading new version. Processing will happen in the background.`
|
||||||
)
|
)
|
||||||
this.newVersionLabel = ''
|
this.newVersionLabel = ''
|
||||||
this.versionUploadState = UploadState.Processing
|
this.versionUploadState.set(UploadState.Processing)
|
||||||
}),
|
}),
|
||||||
map((taskId) =>
|
map((taskId) =>
|
||||||
typeof taskId === 'string'
|
typeof taskId === 'string'
|
||||||
@@ -219,8 +220,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
),
|
),
|
||||||
switchMap((taskId) => {
|
switchMap((taskId) => {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError = $localize`Missing task ID.`
|
this.versionUploadError.set($localize`Missing task ID.`)
|
||||||
return of(null)
|
return of(null)
|
||||||
}
|
}
|
||||||
return merge(
|
return merge(
|
||||||
@@ -240,9 +241,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
switchMap((result) => {
|
switchMap((result) => {
|
||||||
if (result?.state !== 'success') {
|
if (result?.state !== 'success') {
|
||||||
if (result?.state === 'failed') {
|
if (result?.state === 'failed') {
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError =
|
this.versionUploadError.set(
|
||||||
result.message || $localize`Upload failed.`
|
result.message || $localize`Upload failed.`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return of(null)
|
return of(null)
|
||||||
}
|
}
|
||||||
@@ -264,8 +266,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (uploadDocumentId !== this.documentId) return
|
if (uploadDocumentId !== this.documentId) return
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError = error?.message || $localize`Upload failed.`
|
this.versionUploadError.set(
|
||||||
|
error?.message || $localize`Upload failed.`
|
||||||
|
)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error uploading new version`,
|
$localize`Error uploading new version`,
|
||||||
error
|
error
|
||||||
@@ -275,7 +279,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearVersionUploadStatus(): void {
|
clearVersionUploadStatus(): void {
|
||||||
this.versionUploadState = UploadState.Idle
|
this.versionUploadState.set(UploadState.Idle)
|
||||||
this.versionUploadError = null
|
this.versionUploadError.set(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
[createRef]="createTag.bind(this)"
|
[createRef]="createTag.bind(this)"
|
||||||
(opened)="openTagsDropdown()"
|
(opened)="openTagsDropdown()"
|
||||||
[(selectionModel)]="tagSelectionModel"
|
[(selectionModel)]="tagSelectionModel"
|
||||||
[documentCounts]="tagDocumentCounts"
|
[documentCounts]="tagDocumentCounts()"
|
||||||
(apply)="setTags($event)"
|
(apply)="setTags($event)"
|
||||||
shortcutKey="t">
|
shortcutKey="t">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
[createRef]="createCorrespondent.bind(this)"
|
[createRef]="createCorrespondent.bind(this)"
|
||||||
(opened)="openCorrespondentDropdown()"
|
(opened)="openCorrespondentDropdown()"
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
[documentCounts]="correspondentDocumentCounts()"
|
||||||
(apply)="setCorrespondents($event)"
|
(apply)="setCorrespondents($event)"
|
||||||
shortcutKey="y">
|
shortcutKey="y">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
[createRef]="createDocumentType.bind(this)"
|
[createRef]="createDocumentType.bind(this)"
|
||||||
(opened)="openDocumentTypeDropdown()"
|
(opened)="openDocumentTypeDropdown()"
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
[documentCounts]="documentTypeDocumentCounts()"
|
||||||
(apply)="setDocumentTypes($event)"
|
(apply)="setDocumentTypes($event)"
|
||||||
shortcutKey="u">
|
shortcutKey="u">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
[createRef]="createStoragePath.bind(this)"
|
[createRef]="createStoragePath.bind(this)"
|
||||||
(opened)="openStoragePathDropdown()"
|
(opened)="openStoragePathDropdown()"
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
[documentCounts]="storagePathDocumentCounts()"
|
||||||
(apply)="setStoragePaths($event)"
|
(apply)="setStoragePaths($event)"
|
||||||
shortcutKey="i">
|
shortcutKey="i">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
[createRef]="createCustomField.bind(this)"
|
[createRef]="createCustomField.bind(this)"
|
||||||
(opened)="openCustomFieldsDropdown()"
|
(opened)="openCustomFieldsDropdown()"
|
||||||
[(selectionModel)]="customFieldsSelectionModel"
|
[(selectionModel)]="customFieldsSelectionModel"
|
||||||
[documentCounts]="customFieldDocumentCounts"
|
[documentCounts]="customFieldDocumentCounts()"
|
||||||
extraButtonTitle="Set values"
|
extraButtonTitle="Set values"
|
||||||
i18n-extraButtonTitle
|
i18n-extraButtonTitle
|
||||||
(extraButton)="setCustomFieldValues($event)"
|
(extraButton)="setCustomFieldValues($event)"
|
||||||
@@ -103,13 +103,13 @@
|
|||||||
class="btn btn-sm btn-outline-primary"
|
class="btn btn-sm btn-outline-primary"
|
||||||
id="dropdownSend"
|
id="dropdownSend"
|
||||||
ngbDropdownToggle
|
ngbDropdownToggle
|
||||||
[disabled]="disabled || !list.hasSelection || list.allSelected"
|
[disabled]="disabled || !canSendSelection"
|
||||||
>
|
>
|
||||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
|
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
||||||
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
|
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
|
||||||
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||||
@@ -117,18 +117,18 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
@if (emailEnabled) {
|
@if (emailEnabled) {
|
||||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
|
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
||||||
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
|
||||||
@if (!awaitingDownload) {
|
@if (!awaitingDownload()) {
|
||||||
<i-bs name="arrow-down"></i-bs>
|
<i-bs name="arrow-down"></i-bs>
|
||||||
}
|
}
|
||||||
@if (awaitingDownload) {
|
@if (awaitingDownload()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status">
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
<span class="visually-hidden">Preparing download...</span>
|
<span class="visually-hidden">Preparing download...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -208,6 +208,50 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should allow sending an all-filtered selection that fits on the current page', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'hasSelection', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||||
|
.mockReturnValue(5)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.canSendSelection).toBe(true)
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prevent sending an all-filtered selection spanning multiple pages', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'hasSelection', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||||
|
.mockReturnValue(6)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.canSendSelection).toBe(false)
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('should apply selection data to correspondents menu', () => {
|
it('should apply selection data to correspondents menu', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -303,7 +347,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openDocumentTypeDropdown()
|
component.openDocumentTypeDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.documentTypeDocumentCounts).toEqual(
|
expect(component.documentTypeDocumentCounts()).toEqual(
|
||||||
selectionData.selected_document_types
|
selectionData.selected_document_types
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -320,7 +364,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openCorrespondentDropdown()
|
component.openCorrespondentDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.correspondentDocumentCounts).toEqual(
|
expect(component.correspondentDocumentCounts()).toEqual(
|
||||||
selectionData.selected_correspondents
|
selectionData.selected_correspondents
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -337,7 +381,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openStoragePathDropdown()
|
component.openStoragePathDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.storagePathDocumentCounts).toEqual(
|
expect(component.storagePathDocumentCounts()).toEqual(
|
||||||
selectionData.selected_storage_paths
|
selectionData.selected_storage_paths
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -354,7 +398,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openCustomFieldsDropdown()
|
component.openCustomFieldsDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.customFieldDocumentCounts).toEqual(
|
expect(component.customFieldDocumentCounts()).toEqual(
|
||||||
selectionData.selected_custom_fields
|
selectionData.selected_custom_fields
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1597,6 +1641,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(modal.componentInstance.customFields.length).toEqual(2)
|
expect(modal.componentInstance.customFields.length).toEqual(2)
|
||||||
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
|
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
|
||||||
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
|
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
|
||||||
|
expect(modal.componentInstance.selectionCount).toEqual(2)
|
||||||
expect(modal.componentInstance.documents).toEqual([3, 4])
|
expect(modal.componentInstance.documents).toEqual([3, 4])
|
||||||
|
|
||||||
modal.componentInstance.failed.emit()
|
modal.componentInstance.failed.emit()
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -101,12 +108,12 @@ export class BulkEditorComponent
|
|||||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
tagDocumentCounts: SelectionDataItem[]
|
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
correspondentDocumentCounts: SelectionDataItem[]
|
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
documentTypeDocumentCounts: SelectionDataItem[]
|
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
storagePathDocumentCounts: SelectionDataItem[]
|
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
customFieldDocumentCounts: SelectionDataItem[]
|
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
awaitingDownload: boolean
|
readonly awaitingDownload = signal(false)
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
@@ -365,8 +372,8 @@ export class BulkEditorComponent
|
|||||||
openTagsDropdown() {
|
openTagsDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.tagDocumentCounts = selectionData?.selected_tags ?? []
|
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
||||||
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
|
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +381,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.tagDocumentCounts = s.selected_tags
|
this.tagDocumentCounts.set(s.selected_tags)
|
||||||
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
|
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -382,10 +389,11 @@ export class BulkEditorComponent
|
|||||||
openDocumentTypeDropdown() {
|
openDocumentTypeDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.documentTypeDocumentCounts =
|
this.documentTypeDocumentCounts.set(
|
||||||
selectionData?.selected_document_types ?? []
|
selectionData?.selected_document_types ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.documentTypeDocumentCounts,
|
this.documentTypeDocumentCounts(),
|
||||||
this.documentTypeSelectionModel
|
this.documentTypeSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -395,7 +403,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.documentTypeDocumentCounts = s.selected_document_types
|
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_document_types,
|
s.selected_document_types,
|
||||||
this.documentTypeSelectionModel
|
this.documentTypeSelectionModel
|
||||||
@@ -406,10 +414,11 @@ export class BulkEditorComponent
|
|||||||
openCorrespondentDropdown() {
|
openCorrespondentDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.correspondentDocumentCounts =
|
this.correspondentDocumentCounts.set(
|
||||||
selectionData?.selected_correspondents ?? []
|
selectionData?.selected_correspondents ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.correspondentDocumentCounts,
|
this.correspondentDocumentCounts(),
|
||||||
this.correspondentSelectionModel
|
this.correspondentSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -419,7 +428,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.correspondentDocumentCounts = s.selected_correspondents
|
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_correspondents,
|
s.selected_correspondents,
|
||||||
this.correspondentSelectionModel
|
this.correspondentSelectionModel
|
||||||
@@ -430,10 +439,11 @@ export class BulkEditorComponent
|
|||||||
openStoragePathDropdown() {
|
openStoragePathDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.storagePathDocumentCounts =
|
this.storagePathDocumentCounts.set(
|
||||||
selectionData?.selected_storage_paths ?? []
|
selectionData?.selected_storage_paths ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.storagePathDocumentCounts,
|
this.storagePathDocumentCounts(),
|
||||||
this.storagePathsSelectionModel
|
this.storagePathsSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -443,7 +453,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.storagePathDocumentCounts = s.selected_storage_paths
|
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_storage_paths,
|
s.selected_storage_paths,
|
||||||
this.storagePathsSelectionModel
|
this.storagePathsSelectionModel
|
||||||
@@ -454,10 +464,11 @@ export class BulkEditorComponent
|
|||||||
openCustomFieldsDropdown() {
|
openCustomFieldsDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.customFieldDocumentCounts =
|
this.customFieldDocumentCounts.set(
|
||||||
selectionData?.selected_custom_fields ?? []
|
selectionData?.selected_custom_fields ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.customFieldDocumentCounts,
|
this.customFieldDocumentCounts(),
|
||||||
this.customFieldsSelectionModel
|
this.customFieldsSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -467,7 +478,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.customFieldDocumentCounts = s.selected_custom_fields
|
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_custom_fields,
|
s.selected_custom_fields,
|
||||||
this.customFieldsSelectionModel
|
this.customFieldsSelectionModel
|
||||||
@@ -876,7 +887,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadSelected() {
|
downloadSelected() {
|
||||||
this.awaitingDownload = true
|
this.awaitingDownload.set(true)
|
||||||
let downloadFileType: string =
|
let downloadFileType: string =
|
||||||
this.downloadForm.get('downloadFileTypeArchive').value &&
|
this.downloadForm.get('downloadFileTypeArchive').value &&
|
||||||
this.downloadForm.get('downloadFileTypeOriginals').value
|
this.downloadForm.get('downloadFileTypeOriginals').value
|
||||||
@@ -893,7 +904,7 @@ export class BulkEditorComponent
|
|||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((result: any) => {
|
.subscribe((result: any) => {
|
||||||
saveAs(result, 'documents.zip')
|
saveAs(result, 'documents.zip')
|
||||||
this.awaitingDownload = false
|
this.awaitingDownload.set(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,6 +1020,7 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
|
|
||||||
dialog.selection = this.getSelectionQuery()
|
dialog.selection = this.getSelectionQuery()
|
||||||
|
dialog.selectionCount = this.getSelectionSize()
|
||||||
dialog.succeeded.subscribe((result) => {
|
dialog.succeeded.subscribe((result) => {
|
||||||
this.toastService.showInfo($localize`Custom fields updated.`)
|
this.toastService.showInfo($localize`Custom fields updated.`)
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
@@ -1029,6 +1041,14 @@ export class BulkEditorComponent
|
|||||||
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get canSendSelection(): boolean {
|
||||||
|
return (
|
||||||
|
this.list.hasSelection &&
|
||||||
|
(!this.list.allSelected ||
|
||||||
|
this.list.selectedCount === this.list.selected.size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
createShareLinkBundle() {
|
createShareLinkBundle() {
|
||||||
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
|
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title" i8n>{
|
<h4 class="modal-title" id="modal-basic-title" i8n>{
|
||||||
documents.length,
|
documentCount,
|
||||||
plural,
|
plural,
|
||||||
=1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
|
=1 {Set custom fields for 1 document} other {Set custom fields for {{documentCount}} documents}
|
||||||
}</h4>
|
}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+14
@@ -42,6 +42,20 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
|
|||||||
expect(component.form.contains('2')).toBeTruthy()
|
expect(component.form.contains('2')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render the document count for a filtered selection', () => {
|
||||||
|
component.selection = {
|
||||||
|
all: true,
|
||||||
|
filters: { title__icontains: 'invoice' },
|
||||||
|
}
|
||||||
|
component.selectionCount = 42
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.documents).toEqual([])
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.modal-title').textContent
|
||||||
|
).toContain('Set custom fields for 42 documents')
|
||||||
|
})
|
||||||
|
|
||||||
it('should emit succeeded event and close modal on successful save', () => {
|
it('should emit succeeded event and close modal on successful save', () => {
|
||||||
const editSpy = jest
|
const editSpy = jest
|
||||||
.spyOn(documentService, 'bulkEdit')
|
.spyOn(documentService, 'bulkEdit')
|
||||||
|
|||||||
+7
-1
@@ -81,8 +81,14 @@ export class CustomFieldsBulkEditDialogComponent {
|
|||||||
|
|
||||||
public selection: DocumentSelectionQuery = { documents: [] }
|
public selection: DocumentSelectionQuery = { documents: [] }
|
||||||
|
|
||||||
|
public selectionCount: number
|
||||||
|
|
||||||
public get documents(): number[] {
|
public get documents(): number[] {
|
||||||
return this.selection.documents
|
return this.selection.documents ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public get documentCount(): number {
|
||||||
|
return this.selectionCount ?? this.documents.length
|
||||||
}
|
}
|
||||||
|
|
||||||
initForm() {
|
initForm() {
|
||||||
|
|||||||
+5
-1
@@ -2,7 +2,11 @@
|
|||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
||||||
@if (document()) {
|
@if (document()) {
|
||||||
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
@if (priority()) {
|
||||||
|
<img [ngSrc]="getThumbUrl()" fill priority class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||||
|
} @else {
|
||||||
|
<img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="border-end border-bottom bg-light document-card-check">
|
<div class="border-end border-bottom bg-light document-card-check">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
+15
@@ -88,6 +88,21 @@ describe('DocumentCardLargeComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should lazy load the thumbnail', () => {
|
||||||
|
const thumbnail: HTMLImageElement =
|
||||||
|
fixture.nativeElement.querySelector('img.doc-img')
|
||||||
|
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prioritize the thumbnail when requested', () => {
|
||||||
|
fixture.componentRef.setInput('priority', true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
const thumbnail: HTMLImageElement =
|
||||||
|
fixture.nativeElement.querySelector('img.doc-img')
|
||||||
|
expect(thumbnail.getAttribute('loading')).toEqual('eager')
|
||||||
|
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
|
||||||
|
})
|
||||||
|
|
||||||
it('should trim content', () => {
|
it('should trim content', () => {
|
||||||
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
Component,
|
Component,
|
||||||
@@ -46,6 +46,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
TagComponent,
|
TagComponent,
|
||||||
CustomFieldDisplayComponent,
|
CustomFieldDisplayComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
|
NgOptimizedImage,
|
||||||
UsernamePipe,
|
UsernamePipe,
|
||||||
CorrespondentNamePipe,
|
CorrespondentNamePipe,
|
||||||
DocumentTypeNamePipe,
|
DocumentTypeNamePipe,
|
||||||
@@ -65,6 +66,7 @@ export class DocumentCardLargeComponent
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
settingsService = inject(SettingsService)
|
settingsService = inject(SettingsService)
|
||||||
readonly selected = input(false)
|
readonly selected = input(false)
|
||||||
|
readonly priority = input(false)
|
||||||
readonly displayFields = input<string[]>(
|
readonly displayFields = input<string[]>(
|
||||||
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
|
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-1
@@ -2,7 +2,11 @@
|
|||||||
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
|
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
|
||||||
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
||||||
@if (document()) {
|
@if (document()) {
|
||||||
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
|
@if (priority()) {
|
||||||
|
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill priority alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||||
|
} @else {
|
||||||
|
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
|
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
+5
-1
@@ -22,10 +22,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-img-container {
|
||||||
|
position: relative;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.doc-img {
|
.doc-img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: top left;
|
object-position: top left;
|
||||||
height: 180px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card-check {
|
.document-card-check {
|
||||||
|
|||||||
+15
@@ -61,6 +61,21 @@ describe('DocumentCardSmallComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('12 pages')
|
expect(fixture.nativeElement.textContent).toContain('12 pages')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should lazy load the thumbnail', () => {
|
||||||
|
const thumbnail: HTMLImageElement =
|
||||||
|
fixture.nativeElement.querySelector('img.doc-img')
|
||||||
|
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prioritize the thumbnail when requested', () => {
|
||||||
|
fixture.componentRef.setInput('priority', true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
const thumbnail: HTMLImageElement =
|
||||||
|
fixture.nativeElement.querySelector('img.doc-img')
|
||||||
|
expect(thumbnail.getAttribute('loading')).toEqual('eager')
|
||||||
|
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
|
||||||
|
})
|
||||||
|
|
||||||
it('should display a document, limit tags to 5', () => {
|
it('should display a document, limit tags to 5', () => {
|
||||||
expect(fixture.nativeElement.textContent).toContain('Document 10')
|
expect(fixture.nativeElement.textContent).toContain('Document 10')
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
Component,
|
Component,
|
||||||
@@ -46,6 +46,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
TagComponent,
|
TagComponent,
|
||||||
CustomFieldDisplayComponent,
|
CustomFieldDisplayComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
|
NgOptimizedImage,
|
||||||
UsernamePipe,
|
UsernamePipe,
|
||||||
CorrespondentNamePipe,
|
CorrespondentNamePipe,
|
||||||
DocumentTypeNamePipe,
|
DocumentTypeNamePipe,
|
||||||
@@ -65,6 +66,7 @@ export class DocumentCardSmallComponent
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
settingsService = inject(SettingsService)
|
settingsService = inject(SettingsService)
|
||||||
readonly selected = input(false)
|
readonly selected = input(false)
|
||||||
|
readonly priority = input(false)
|
||||||
readonly document = input<Document>(undefined)
|
readonly document = input<Document>(undefined)
|
||||||
readonly displayFields = input<string[]>(
|
readonly displayFields = input<string[]>(
|
||||||
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
|
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
||||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,9 +164,10 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
|
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
|
||||||
<div>
|
<div>
|
||||||
@for (d of list.documents; track d.id) {
|
@for (d of list.documents; track d.id; let i = $index) {
|
||||||
<pngx-document-card-large
|
<pngx-document-card-large
|
||||||
[selected]="list.isSelected(d)"
|
[selected]="list.isSelected(d)"
|
||||||
|
[priority]="i < 2"
|
||||||
(toggleSelected)="toggleSelected(d, $event)"
|
(toggleSelected)="toggleSelected(d, $event)"
|
||||||
(dblClickDocument)="openDocumentDetail(d)"
|
(dblClickDocument)="openDocumentDetail(d)"
|
||||||
[document]="d"
|
[document]="d"
|
||||||
@@ -398,9 +399,10 @@
|
|||||||
}
|
}
|
||||||
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
|
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
|
||||||
<div class="row row-cols-paperless-cards">
|
<div class="row row-cols-paperless-cards">
|
||||||
@for (d of list.documents; track d.id) {
|
@for (d of list.documents; track d.id; let i = $index) {
|
||||||
<pngx-document-card-small class="p-0"
|
<pngx-document-card-small class="p-0"
|
||||||
[selected]="list.isSelected(d)"
|
[selected]="list.isSelected(d)"
|
||||||
|
[priority]="i < 6"
|
||||||
(toggleSelected)="toggleSelected(d, $event)"
|
(toggleSelected)="toggleSelected(d, $event)"
|
||||||
(dblClickDocument)="openDocumentDetail(d)"
|
(dblClickDocument)="openDocumentDetail(d)"
|
||||||
[document]="d"
|
[document]="d"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -149,7 +150,7 @@ export class DocumentListComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
unmodifiedFilterRules: FilterRule[] = []
|
readonly unmodifiedFilterRules = signal<FilterRule[]>([])
|
||||||
private unmodifiedSavedView: SavedView
|
private unmodifiedSavedView: SavedView
|
||||||
private activeSavedView: SavedView | null = null
|
private activeSavedView: SavedView | null = null
|
||||||
|
|
||||||
@@ -299,7 +300,7 @@ export class DocumentListComponent
|
|||||||
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
||||||
})
|
})
|
||||||
this.updateDisplayCustomFields()
|
this.updateDisplayCustomFields()
|
||||||
this.unmodifiedFilterRules = view.filter_rules
|
this.unmodifiedFilterRules.set(view.filter_rules)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.route.queryParamMap
|
this.route.queryParamMap
|
||||||
@@ -316,7 +317,7 @@ export class DocumentListComponent
|
|||||||
this.activeSavedView = null
|
this.activeSavedView = null
|
||||||
this.list.activateSavedView(null)
|
this.list.activateSavedView(null)
|
||||||
this.list.loadFromQueryParams(queryParams)
|
this.list.loadFromQueryParams(queryParams)
|
||||||
this.unmodifiedFilterRules = []
|
this.unmodifiedFilterRules.set([])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -415,7 +416,7 @@ export class DocumentListComponent
|
|||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.unmodifiedFilterRules = this.list.filterRules
|
this.unmodifiedFilterRules.set(this.list.filterRules)
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
shortcutKey="i"></pngx-filterable-dropdown>
|
shortcutKey="i"></pngx-filterable-dropdown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) {
|
||||||
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
|
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
|
||||||
[(selectionModel)]="customFieldQueriesModel"
|
[(selectionModel)]="customFieldQueriesModel"
|
||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -349,7 +351,7 @@ export class FilterEditorComponent
|
|||||||
@ViewChild('textFilterInput')
|
@ViewChild('textFilterInput')
|
||||||
textFilterInput: ElementRef
|
textFilterInput: ElementRef
|
||||||
|
|
||||||
customFields: CustomField[] = []
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
tagDocumentCounts: SelectionDataItem[]
|
tagDocumentCounts: SelectionDataItem[]
|
||||||
correspondentDocumentCounts: SelectionDataItem[]
|
correspondentDocumentCounts: SelectionDataItem[]
|
||||||
@@ -514,6 +516,7 @@ export class FilterEditorComponent
|
|||||||
this.documentService.get(this._moreLikeId).subscribe((result) => {
|
this.documentService.get(this._moreLikeId).subscribe((result) => {
|
||||||
this._moreLikeDoc = result
|
this._moreLikeDoc = result
|
||||||
this._textFilter = result.title
|
this._textFilter = result.title
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case FILTER_CREATED_AFTER:
|
case FILTER_CREATED_AFTER:
|
||||||
@@ -1162,6 +1165,7 @@ export class FilterEditorComponent
|
|||||||
|
|
||||||
private loadingCountTotal: number = 0
|
private loadingCountTotal: number = 0
|
||||||
private loadingCount: number = 0
|
private loadingCount: number = 0
|
||||||
|
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
private maybeCompleteLoading() {
|
private maybeCompleteLoading() {
|
||||||
this.loadingCount++
|
this.loadingCount++
|
||||||
@@ -1229,7 +1233,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
this.loadingCountTotal++
|
this.loadingCountTotal++
|
||||||
this.customFieldService.listAll().subscribe((result) => {
|
this.customFieldService.listAll().subscribe((result) => {
|
||||||
this.customFields = result.results
|
this.customFields.set(result.results)
|
||||||
this.maybeCompleteLoading()
|
this.maybeCompleteLoading()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user