mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-24 10:20:31 +00:00
Merge branch 'beta' into dev
This commit is contained in:
@@ -26,7 +26,7 @@ module.exports = {
|
||||
'abstract-paperless-service',
|
||||
],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|@angular/common/locales/.*\\.js$))',
|
||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
...esmPreset.moduleNameMapper,
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"ngx-cookie-service": "^21.3.1",
|
||||
"ngx-device-detector": "^11.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
||||
"normalize-diacritics": "^5.0.0",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
|
||||
Generated
+11
@@ -71,6 +71,9 @@ importers:
|
||||
ngx-ui-tour-ng-bootstrap:
|
||||
specifier: ^18.0.0
|
||||
version: 18.0.0(4ccfccfbcf381a309618492b31e99276)
|
||||
normalize-diacritics:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
pdfjs-dist:
|
||||
specifier: ^5.7.284
|
||||
version: 5.7.284
|
||||
@@ -5565,6 +5568,10 @@ packages:
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
hasBin: true
|
||||
|
||||
normalize-diacritics@5.0.0:
|
||||
resolution: {integrity: sha512-t6czCJOpbAtckN1wCC2qPWnO3GQvNANb9bcUNbiOLEqojVuP31+ELIs5KhEG8jyz0TH7iD9BWxWz8O3ic2/rMQ==}
|
||||
engines: {node: '>= 14.x', npm: '>= 6.x'}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -12985,6 +12992,10 @@ snapshots:
|
||||
dependencies:
|
||||
abbrev: 4.0.0
|
||||
|
||||
normalize-diacritics@5.0.0:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
npm-bundled@5.0.0:
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
||||
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks === 0">
|
||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
||||
</button>
|
||||
<div class="form-check form-switch mb-0 ms-2">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
@@ -81,7 +84,7 @@
|
||||
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
@for (t of filterTargets; track t.id) {
|
||||
<button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
|
||||
<button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="setFilterTarget(t.id)">{{t.name}}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { throwError } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import {
|
||||
PaperlessTask,
|
||||
@@ -29,7 +29,11 @@ import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { TasksComponent, TaskSection } from './tasks.component'
|
||||
import {
|
||||
TaskFilterTargetID,
|
||||
TasksComponent,
|
||||
TaskSection,
|
||||
} from './tasks.component'
|
||||
|
||||
const tasks: PaperlessTask[] = [
|
||||
{
|
||||
@@ -154,6 +158,13 @@ const paginatedTasks: Results<PaperlessTask> = {
|
||||
results: tasks,
|
||||
}
|
||||
|
||||
const sectionCountResponse = {
|
||||
all: 7,
|
||||
needs_attention: 2,
|
||||
in_progress: 3,
|
||||
completed: 2,
|
||||
}
|
||||
|
||||
describe('TasksComponent', () => {
|
||||
let component: TasksComponent
|
||||
let fixture: ComponentFixture<TasksComponent>
|
||||
@@ -221,6 +232,15 @@ describe('TasksComponent', () => {
|
||||
req.params.get('page') === '1'
|
||||
)
|
||||
.flush(paginatedTasks)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
!req.params.has('status')
|
||||
)
|
||||
.flush(sectionCountResponse)
|
||||
})
|
||||
|
||||
it('should display task sections with counts', () => {
|
||||
@@ -295,6 +315,7 @@ describe('TasksComponent', () => {
|
||||
const headerText = header.nativeElement.textContent
|
||||
|
||||
expect(headerText).toContain('Dismiss visible')
|
||||
expect(headerText).toContain('Dismiss all')
|
||||
expect(headerText).toContain('Auto refresh')
|
||||
expect(headerText).not.toContain('All types')
|
||||
expect(headerText).not.toContain('All sources')
|
||||
@@ -327,6 +348,74 @@ describe('TasksComponent', () => {
|
||||
expect(pagination).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should apply the selected section to the server-side task query', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page') === '1' &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('acknowledged') === 'false' &&
|
||||
request.params.getAll('status').includes(PaperlessTaskStatus.Failure) &&
|
||||
request.params.getAll('status').includes(PaperlessTaskStatus.Revoked)
|
||||
)
|
||||
|
||||
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
|
||||
expect(component.totalTasks).toBe(2)
|
||||
})
|
||||
|
||||
it('should apply task type and trigger source filters to the server-side task query', () => {
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[6]] })
|
||||
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
|
||||
request.params.get('trigger_source') ===
|
||||
PaperlessTaskTriggerSource.System
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[6]] })
|
||||
})
|
||||
|
||||
it('should apply text filters to the server-side task query', () => {
|
||||
component.filterText = 'invoice'
|
||||
jest.advanceTimersByTime(150)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('name') === 'invoice'
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[0]] })
|
||||
|
||||
component.setFilterTarget(TaskFilterTargetID.Result)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('result') === 'invoice'
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
})
|
||||
|
||||
it('should load a different task page when pagination changes', () => {
|
||||
component.setPage(2)
|
||||
|
||||
@@ -350,6 +439,27 @@ describe('TasksComponent', () => {
|
||||
expect(component.pagedTasks).toEqual([tasks[0]])
|
||||
})
|
||||
|
||||
it('should not replace section counts with current-page counts', () => {
|
||||
component.setPage(2)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '25' &&
|
||||
req.params.get('page') === '2'
|
||||
)
|
||||
.flush({
|
||||
count: 30,
|
||||
results: [tasks[0]],
|
||||
})
|
||||
|
||||
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
|
||||
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
|
||||
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
|
||||
})
|
||||
|
||||
it('should expose stable task type options and disable empty ones', () => {
|
||||
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
||||
PaperlessTaskType.TrainClassifier
|
||||
@@ -495,6 +605,46 @@ describe('TasksComponent', () => {
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([467, 466]))
|
||||
})
|
||||
|
||||
it('should support dismiss all tasks', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const dismissSpy = jest
|
||||
.spyOn(tasksService, 'dismissAllTasks')
|
||||
.mockReturnValue(of({}))
|
||||
const reloadPageSpy = jest
|
||||
.spyOn(component as any, 'reloadPage')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
component.dismissAllTasks()
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
expect(modal.componentInstance.messageBold).toBe('Dismiss all 7 tasks?')
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
expect(dismissSpy).toHaveBeenCalled()
|
||||
expect(reloadPageSpy).toHaveBeenCalledWith(false)
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should show an error and re-enable modal buttons when dismissing all tasks fails', () => {
|
||||
const error = new Error('dismiss all failed')
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
const dismissSpy = jest
|
||||
.spyOn(tasksService, 'dismissAllTasks')
|
||||
.mockReturnValue(throwError(() => error))
|
||||
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
|
||||
component.dismissAllTasks()
|
||||
expect(modal).not.toBeUndefined()
|
||||
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
@@ -673,6 +823,9 @@ describe('TasksComponent', () => {
|
||||
})
|
||||
|
||||
it('should keep clearing selection independent from resetting filters', () => {
|
||||
component.resetFilter()
|
||||
expect(component.filterText).toBe('')
|
||||
|
||||
component.setTaskType(PaperlessTaskType.ConsumeFile)
|
||||
component.toggleSelected(tasks[0])
|
||||
expect(component.selectedTasks.size).toBe(1)
|
||||
|
||||
@@ -40,7 +40,7 @@ export enum TaskSection {
|
||||
Completed = 'completed',
|
||||
}
|
||||
|
||||
enum TaskFilterTargetID {
|
||||
export enum TaskFilterTargetID {
|
||||
Name,
|
||||
Result,
|
||||
}
|
||||
@@ -167,6 +167,12 @@ export class TasksComponent
|
||||
public readonly pageSize = 25
|
||||
public page: number = 1
|
||||
public totalTasks: number = 0
|
||||
public sectionCounts: Record<TaskSection, number> = {
|
||||
[TaskSection.All]: 0,
|
||||
[TaskSection.NeedsAttention]: 0,
|
||||
[TaskSection.InProgress]: 0,
|
||||
[TaskSection.Completed]: 0,
|
||||
}
|
||||
public pagedTasks: PaperlessTask[] = []
|
||||
public selectedSection: TaskSection = TaskSection.All
|
||||
public selectedTaskType: PaperlessTaskType | null = null
|
||||
@@ -282,6 +288,7 @@ export class TasksComponent
|
||||
.subscribe((query) => {
|
||||
this._filterText = query
|
||||
this.clearSelection()
|
||||
this.reloadPage(true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -334,6 +341,30 @@ export class TasksComponent
|
||||
}
|
||||
}
|
||||
|
||||
dismissAllTasks() {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
||||
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks} tasks?`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.tasksService.dismissAllTasks().subscribe({
|
||||
next: () => {
|
||||
this.reloadPage(false)
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
},
|
||||
})
|
||||
this.clearSelection()
|
||||
})
|
||||
}
|
||||
|
||||
expandTask(task: PaperlessTask) {
|
||||
this.expandedTask = this.expandedTask == task.id ? undefined : task.id
|
||||
}
|
||||
@@ -446,9 +477,7 @@ export class TasksComponent
|
||||
}
|
||||
|
||||
sectionCount(section: TaskSection): number {
|
||||
return this.pagedTasks.filter((task) =>
|
||||
this.taskBelongsToSection(task, section)
|
||||
).length
|
||||
return this.sectionCounts[section]
|
||||
}
|
||||
|
||||
sectionShowsResults(section: TaskSection): boolean {
|
||||
@@ -458,16 +487,27 @@ export class TasksComponent
|
||||
setSection(section: TaskSection) {
|
||||
this.selectedSection = section
|
||||
this.clearSelection()
|
||||
this.reloadPage(true)
|
||||
}
|
||||
|
||||
setTaskType(taskType: PaperlessTaskType | null) {
|
||||
this.selectedTaskType = taskType
|
||||
this.clearSelection()
|
||||
this.reloadPage(true)
|
||||
}
|
||||
|
||||
setTriggerSource(triggerSource: PaperlessTaskTriggerSource | null) {
|
||||
this.selectedTriggerSource = triggerSource
|
||||
this.clearSelection()
|
||||
this.reloadPage(true)
|
||||
}
|
||||
|
||||
setFilterTarget(filterTargetID: TaskFilterTargetID) {
|
||||
this.filterTargetID = filterTargetID
|
||||
if (this._filterText.length) {
|
||||
this.clearSelection()
|
||||
this.reloadPage(true)
|
||||
}
|
||||
}
|
||||
|
||||
taskTypeOptionCount(taskType: PaperlessTaskType | null): number {
|
||||
@@ -505,19 +545,32 @@ export class TasksComponent
|
||||
}
|
||||
|
||||
public resetFilter() {
|
||||
if (!this._filterText.length) {
|
||||
return
|
||||
}
|
||||
|
||||
this._filterText = ''
|
||||
this.clearSelection()
|
||||
this.reloadPage(true)
|
||||
}
|
||||
|
||||
public resetFilters() {
|
||||
const hadFilter = this.isFiltered
|
||||
this.selectedTaskType = null
|
||||
this.selectedTriggerSource = null
|
||||
this.resetFilter()
|
||||
this._filterText = ''
|
||||
this.clearSelection()
|
||||
|
||||
if (hadFilter) {
|
||||
this.reloadPage(true)
|
||||
}
|
||||
}
|
||||
|
||||
filterInputKeyup(event: KeyboardEvent) {
|
||||
if (event.key == 'Enter') {
|
||||
this._filterText = (event.target as HTMLInputElement).value
|
||||
this.clearSelection()
|
||||
this.reloadPage(true)
|
||||
} else if (event.key === 'Escape') {
|
||||
this.resetFilter()
|
||||
}
|
||||
@@ -606,19 +659,86 @@ export class TasksComponent
|
||||
)
|
||||
}
|
||||
|
||||
private reloadSectionCounts() {
|
||||
this.tasksService
|
||||
.statusCounts(this.getParamsForSection(TaskSection.All))
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((counts) => {
|
||||
this.sectionCounts[TaskSection.All] = counts.all
|
||||
this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
|
||||
this.sectionCounts[TaskSection.InProgress] = counts.in_progress
|
||||
this.sectionCounts[TaskSection.Completed] = counts.completed
|
||||
})
|
||||
}
|
||||
|
||||
private getParamsForSection(
|
||||
section: TaskSection
|
||||
): Record<string, string | number | boolean | readonly string[]> {
|
||||
const params: Record<
|
||||
string,
|
||||
string | number | boolean | readonly string[]
|
||||
> = {
|
||||
acknowledged: false,
|
||||
}
|
||||
|
||||
const statuses = this.statusesForSection(section)
|
||||
if (statuses.length) {
|
||||
params.status = statuses
|
||||
}
|
||||
|
||||
if (this.selectedTaskType !== null) {
|
||||
params.task_type = this.selectedTaskType
|
||||
}
|
||||
|
||||
if (this.selectedTriggerSource !== null) {
|
||||
params.trigger_source = this.selectedTriggerSource
|
||||
}
|
||||
|
||||
if (this._filterText.length) {
|
||||
params[
|
||||
this.filterTargetID === TaskFilterTargetID.Name ? 'name' : 'result'
|
||||
] = this._filterText
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
private statusesForSection(section: TaskSection): PaperlessTaskStatus[] {
|
||||
switch (section) {
|
||||
case TaskSection.NeedsAttention:
|
||||
return [PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked]
|
||||
case TaskSection.InProgress:
|
||||
return [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started]
|
||||
case TaskSection.Completed:
|
||||
return [PaperlessTaskStatus.Success]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private reloadPage(resetToFirstPage: boolean = false) {
|
||||
if (resetToFirstPage) {
|
||||
this.page = 1
|
||||
}
|
||||
|
||||
this.reloadSectionCounts()
|
||||
|
||||
this.loading = true
|
||||
this.tasksService
|
||||
.list(this.page, this.pageSize, { acknowledged: false })
|
||||
.list(
|
||||
this.page,
|
||||
this.pageSize,
|
||||
this.getParamsForSection(this.selectedSection)
|
||||
)
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.pagedTasks = result.results
|
||||
this.totalTasks = result.count
|
||||
this.sectionCounts[TaskSection.All] = result.count
|
||||
if (this.selectedSection !== TaskSection.All) {
|
||||
this.sectionCounts[this.selectedSection] = result.count
|
||||
}
|
||||
this.loading = false
|
||||
if (
|
||||
this.page > 1 &&
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="chat-messages font-monospace small">
|
||||
@for (message of messages; track message) {
|
||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||
<div class="p-2 m-2" [class.bg-dark]="message.role === 'user'">
|
||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||
<span>
|
||||
{{ message.content }}
|
||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||
|
||||
@@ -188,4 +188,14 @@ describe('ChatComponent', () => {
|
||||
component.searchInputKeyDown(event)
|
||||
expect(component.sendMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not send message on Enter key press while composing with IME', () => {
|
||||
jest.spyOn(component, 'sendMessage')
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
isComposing: true,
|
||||
})
|
||||
component.searchInputKeyDown(event)
|
||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -155,7 +155,10 @@ export class ChatComponent implements OnInit {
|
||||
}
|
||||
|
||||
public searchInputKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!(event.isComposing || event.keyCode === 229)
|
||||
) {
|
||||
event.preventDefault()
|
||||
this.sendMessage()
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (messageBold) {
|
||||
<p><b>{{messageBold}}</b></p>
|
||||
<p class="text-break"><b>{{messageBold}}</b></p>
|
||||
}
|
||||
@if (message) {
|
||||
<p class="mb-0" [innerHTML]="message"></p>
|
||||
<p class="mb-0 text-break" [innerHTML]="message"></p>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
+5
-1
@@ -9,8 +9,11 @@
|
||||
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
|
||||
<ul class="list-group"
|
||||
cdkDropList
|
||||
[cdkDropListData]="documentIDs"
|
||||
(cdkDropListDropped)="onDrop($event)">
|
||||
@for (document of documents; track document.id) {
|
||||
@for (documentID of documentIDs; track documentID) {
|
||||
@let document = getDocument(documentID);
|
||||
@if (document) {
|
||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||
<div class="d-flex flex-column">
|
||||
@@ -27,6 +30,7 @@
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+2
-3
@@ -23,6 +23,7 @@ import {
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
|
||||
@@ -69,9 +70,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
||||
|
||||
public get filteredFields(): CustomField[] {
|
||||
return this.unusedFields.filter(
|
||||
(f) =>
|
||||
!this.filterText ||
|
||||
f.name.toLowerCase().includes(this.filterText.toLowerCase())
|
||||
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -63,6 +63,7 @@
|
||||
[(ngModel)]="atom.value"
|
||||
[disabled]="disabled"
|
||||
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
||||
[searchFn]="selectOptionSearchFn"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
||||
@@ -81,6 +82,7 @@
|
||||
[disabled]="disabled"
|
||||
bindLabel="name"
|
||||
bindValue="id"
|
||||
[searchFn]="customFieldSearchFn"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
|
||||
@@ -125,6 +127,7 @@
|
||||
[(ngModel)]="atom.value"
|
||||
[disabled]="disabled"
|
||||
[multiple]="true"
|
||||
[searchFn]="selectOptionSearchFn"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
}
|
||||
|
||||
+9
@@ -36,6 +36,7 @@ import {
|
||||
CustomFieldQueryExpression,
|
||||
} from 'src/app/utils/custom-field-query-element'
|
||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||
@@ -281,6 +282,14 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
|
||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||
|
||||
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
|
||||
matchesSearchText(field?.name, term)
|
||||
|
||||
public selectOptionSearchFn = (
|
||||
term: string,
|
||||
option: { id: string; label: string }
|
||||
): boolean => matchesSearchText(option?.label, term)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.selectionModel = new CustomFieldQueriesModel()
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
[searchFn]="searchFn"
|
||||
bindValue="id"
|
||||
[virtualScroll]="items?.length > 100"
|
||||
(change)="onChange(value)"
|
||||
|
||||
@@ -112,6 +112,15 @@ describe('SelectComponent', () => {
|
||||
expect(createNewVal).toEqual('baz')
|
||||
})
|
||||
|
||||
it('should search items by independent normalized terms', () => {
|
||||
expect(
|
||||
component.searchFn('tax 26', { id: 11, name: 'Tax\u00e9s 2026' })
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.searchFn('tax receipt', { id: 11, name: 'Tax\u00e9s 2026' })
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should clear search term on blur after delay', fakeAsync(() => {
|
||||
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
|
||||
component.onBlur()
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
|
||||
@Component({
|
||||
@@ -99,6 +100,9 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
||||
@Input()
|
||||
bindLabel: string = 'name'
|
||||
|
||||
public searchFn = (term: string, item: any): boolean =>
|
||||
matchesSearchText(item?.[this.bindLabel], term)
|
||||
|
||||
@Input()
|
||||
showFilter: boolean = false
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[clearSearchOnAdd]="true"
|
||||
[hideSelected]="tags.length > 0"
|
||||
[addTag]="allowCreate ? createTagRef : false"
|
||||
[searchFn]="searchFn"
|
||||
addTagText="Add tag"
|
||||
i18n-addTagText
|
||||
(add)="onAdd($event)"
|
||||
|
||||
@@ -171,6 +171,15 @@ describe('TagsComponent', () => {
|
||||
expect(component.getTag(4)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should search tags by independent normalized terms including parents', () => {
|
||||
const parent: Tag = { id: 11, name: 'Financ\u00e9' }
|
||||
const child: Tag = { id: 12, name: 'Taxes 2026', parent: parent.id }
|
||||
component.tags = [parent, child]
|
||||
|
||||
expect(component.searchFn('finance 26', child)).toBeTruthy()
|
||||
expect(component.searchFn('finance receipt', child)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should emit filtered documents', () => {
|
||||
component.value = [10]
|
||||
component.tags = tags
|
||||
|
||||
@@ -21,6 +21,7 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first, firstValueFrom, tap } from 'rxjs'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
||||
import { EditDialogMode } from '../../edit-dialog/edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { TagComponent } from '../../tag/tag.component'
|
||||
@@ -114,6 +115,14 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
public createTagRef: (name) => void
|
||||
|
||||
public searchFn = (term: string, tag: Tag): boolean =>
|
||||
matchesSearchText(
|
||||
[this.getParentChain(tag?.id).map((parent) => parent.name), tag?.name]
|
||||
.flat()
|
||||
.join(' '),
|
||||
term
|
||||
)
|
||||
|
||||
getTag(id: number) {
|
||||
if (this.tags) {
|
||||
return this.tags.find((tag) => tag.id == id)
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="loading || (suggestions && !aiEnabled)">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled || loading || (suggestions && !aiEnabled)">
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
} @else {
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
@if (aiEnabled) {
|
||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="loading || !suggestions" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled || loading || !suggestions" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
</button>
|
||||
|
||||
|
||||
+12
@@ -37,6 +37,18 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
expect(component.getSuggestions.emit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit getSuggestions when disabled', () => {
|
||||
jest.spyOn(component.getSuggestions, 'emit')
|
||||
component.disabled = true
|
||||
component.suggestions = null
|
||||
fixture.detectChanges()
|
||||
|
||||
component.clickSuggest()
|
||||
|
||||
expect(component.getSuggestions.emit).not.toHaveBeenCalled()
|
||||
expect(fixture.nativeElement.querySelector('button').disabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
||||
component.aiEnabled = true
|
||||
fixture.detectChanges()
|
||||
|
||||
+8
@@ -47,6 +47,14 @@ export class SuggestionsDropdownComponent {
|
||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
||||
|
||||
public clickSuggest(): void {
|
||||
if (
|
||||
this.disabled ||
|
||||
this.loading ||
|
||||
(this.suggestions && !this.aiEnabled)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.suggestions) {
|
||||
this.getSuggestions.emit(this)
|
||||
} else {
|
||||
|
||||
+3
-1
@@ -131,7 +131,9 @@
|
||||
@if (status.tasks.celery_status === 'OK') {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
|
||||
[class.text-danger]="status.tasks.celery_status === SystemStatusItemStatus.ERROR"
|
||||
[class.text-warning]="status.tasks.celery_status === SystemStatusItemStatus.WARNING"></i-bs>
|
||||
}
|
||||
</button>
|
||||
<ng-template #celeryStatus>
|
||||
|
||||
@@ -360,6 +360,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
category: ConfigCategory.AI,
|
||||
note: $localize`Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.`,
|
||||
},
|
||||
{
|
||||
key: 'llm_request_timeout',
|
||||
title: $localize`LLM Request Timeout`,
|
||||
type: ConfigOptionType.Number,
|
||||
config_key: 'PAPERLESS_AI_LLM_REQUEST_TIMEOUT',
|
||||
category: ConfigCategory.AI,
|
||||
note: $localize`Timeout in seconds for LLM requests.`,
|
||||
},
|
||||
]
|
||||
|
||||
export interface PaperlessConfig extends ObjectWithId {
|
||||
@@ -401,4 +409,5 @@ export interface PaperlessConfig extends ObjectWithId {
|
||||
llm_api_key: string
|
||||
llm_endpoint: string
|
||||
llm_output_language: string
|
||||
llm_request_timeout: number
|
||||
}
|
||||
|
||||
@@ -64,3 +64,10 @@ export interface PaperlessTaskSummary {
|
||||
last_success: Date | null
|
||||
last_failure: Date | null
|
||||
}
|
||||
|
||||
export interface PaperlessTaskStatusCounts {
|
||||
all: number
|
||||
needs_attention: number
|
||||
in_progress: number
|
||||
completed: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { MatchingModel } from '../data/matching-model'
|
||||
import { matchesSearchText } from '../utils/text-search'
|
||||
|
||||
@Pipe({
|
||||
name: 'filter',
|
||||
@@ -21,9 +22,7 @@ export class FilterPipe implements PipeTransform {
|
||||
typeof item[key] === 'string' || typeof item[key] === 'number'
|
||||
)
|
||||
return keys.some((key) => {
|
||||
return String(item[key])
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase())
|
||||
return matchesSearchText(item[key], searchText)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,6 +80,27 @@ describe('TasksService', () => {
|
||||
.flush({ count: 0, results: [] })
|
||||
})
|
||||
|
||||
it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => {
|
||||
tasksService.dismissAllTasks().subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/acknowledge/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
all: true,
|
||||
})
|
||||
req.flush([])
|
||||
// reload is then called
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '1000'
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
})
|
||||
|
||||
it('groups mixed task types by status when reloading', () => {
|
||||
expect(tasksService.total).toEqual(0)
|
||||
const mockTasks = [
|
||||
@@ -221,4 +242,34 @@ describe('TasksService', () => {
|
||||
task_id: 'abc-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('loads filtered task status counts', () => {
|
||||
tasksService
|
||||
.statusCounts({
|
||||
acknowledged: false,
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
})
|
||||
.subscribe((res) => {
|
||||
expect(res).toEqual({
|
||||
all: 10,
|
||||
needs_attention: 2,
|
||||
in_progress: 3,
|
||||
completed: 5,
|
||||
})
|
||||
})
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
(req: HttpRequest<unknown>) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('task_type') === PaperlessTaskType.ConsumeFile
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
req.flush({
|
||||
all: 10,
|
||||
needs_attention: 2,
|
||||
in_progress: 3,
|
||||
completed: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { first, map, takeUntil, tap } from 'rxjs/operators'
|
||||
import {
|
||||
PaperlessTask,
|
||||
PaperlessTaskStatus,
|
||||
PaperlessTaskStatusCounts,
|
||||
PaperlessTaskType,
|
||||
} from 'src/app/data/paperless-task'
|
||||
import { Results } from 'src/app/data/results'
|
||||
@@ -88,7 +89,7 @@ export class TasksService {
|
||||
public list(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
extraParams?: Record<string, string | number | boolean>
|
||||
extraParams?: Record<string, string | number | boolean | readonly string[]>
|
||||
): Observable<Results<PaperlessTask>> {
|
||||
return this.http.get<Results<PaperlessTask>>(
|
||||
`${this.baseUrl}${this.endpoint}/`,
|
||||
@@ -102,6 +103,17 @@ export class TasksService {
|
||||
)
|
||||
}
|
||||
|
||||
public statusCounts(
|
||||
extraParams?: Record<string, string | number | boolean | readonly string[]>
|
||||
): Observable<PaperlessTaskStatusCounts> {
|
||||
return this.http.get<PaperlessTaskStatusCounts>(
|
||||
`${this.baseUrl}${this.endpoint}/status_counts/`,
|
||||
{
|
||||
params: extraParams,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public dismissTasks(task_ids: Set<number>): Observable<any> {
|
||||
return this.http
|
||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||
@@ -116,6 +128,20 @@ export class TasksService {
|
||||
)
|
||||
}
|
||||
|
||||
public dismissAllTasks(): Observable<any> {
|
||||
return this.http
|
||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||
all: true,
|
||||
})
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifer),
|
||||
tap(() => {
|
||||
this.reload()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
public cancelPending(): void {
|
||||
this.unsubscribeNotifer.next(true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { matchesSearchText } from './text-search'
|
||||
|
||||
describe('text search utilities', () => {
|
||||
it('matches text accent-insensitively', () => {
|
||||
expect(matchesSearchText('R\u00e9sum\u00e9', 'resume')).toBeTruthy()
|
||||
expect(matchesSearchText('S\u00f8ren', 'soren')).toBeTruthy()
|
||||
expect(matchesSearchText('\u0152uvre', 'oeuvre')).toBeTruthy()
|
||||
expect(matchesSearchText('Invoice', 'receipt')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('matches all whitespace-separated search terms independently', () => {
|
||||
expect(matchesSearchText('taxes 2026', 'tax 26')).toBeTruthy()
|
||||
expect(matchesSearchText('2026 taxes', 'tax 26')).toBeTruthy()
|
||||
expect(matchesSearchText('Tax\u00e9s 2026', 'taxe 26')).toBeTruthy()
|
||||
expect(matchesSearchText('taxes 2026', 'tax receipt')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { normalizeSync } from 'normalize-diacritics'
|
||||
|
||||
export type SearchTextValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| bigint
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export function normalizeSearchText(value: SearchTextValue): string {
|
||||
return normalizeSync(String(value ?? '')).toLocaleLowerCase()
|
||||
}
|
||||
|
||||
export function matchesSearchText(
|
||||
value: SearchTextValue,
|
||||
searchText: SearchTextValue
|
||||
): boolean {
|
||||
const normalizedValue = normalizeSearchText(value)
|
||||
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/)
|
||||
|
||||
return searchTerms.every((term) => normalizedValue.includes(term))
|
||||
}
|
||||
Reference in New Issue
Block a user