Merge branch 'dev' into feature/ocr-templates

This commit is contained in:
shamoon committed 2026-10-19 21:37:23 -07:00
commit cf6d16edcd
516 files changed
+137596 -100899

No files matched your search

@@ -580,7 +580,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
})
it('should clear all-selected mode when toggling a single document', () => {
it('should exclude a toggled document while keeping all-selected mode', () => {
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -592,8 +592,73 @@ describe('DocumentListViewService', () => {
documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.allSelected).toBeFalsy()
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.excluded).toEqual(new Set([documents[0].id]))
expect(documentListViewService.selectedCount).toEqual(documents.length - 1)
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.excluded.size).toEqual(0)
expect(documentListViewService.selectedCount).toEqual(documents.length)
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
})
it('should preserve exclusions across pages', () => {
documentListViewService.pageSize = 3
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush({ count: documents.length, results: documents.slice(0, 3) })
documentListViewService.selectAll()
documentListViewService.toggleSelected(documents[0])
documentListViewService.currentPage = 2
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush({ count: documents.length, results: documents.slice(3, 6) })
expect(documentListViewService.excluded).toEqual(new Set([documents[0].id]))
expect(documentListViewService.selectedCount).toEqual(documents.length - 1)
expect(documentListViewService.selected).toEqual(
new Set(documents.slice(3, 6).map((document) => document.id))
)
documentListViewService.currentPage = 1
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush({ count: documents.length, results: documents.slice(0, 3) })
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
})
it('should clear exclusions when filters change', () => {
documentListViewService.reload()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush(full_results)
documentListViewService.selectAll()
documentListViewService.toggleSelected(documents[0])
documentListViewService.setFilterRules(filterRules)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
)
req.flush({ count: 3, results: documents.slice(0, 3) })
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.excluded.size).toEqual(0)
expect(documentListViewService.selectedCount).toEqual(3)
documentListViewService.setFilterRules([])
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
req.flush(full_results)
})
it('should clear all-selected mode when selecting a range', () => {
@@ -85,6 +85,11 @@ export interface ListViewState {
*/
allSelected?: boolean
/**
* Document IDs excluded from the full filtered result set.
*/
excluded?: Set<number>
/**
* The page size of the list view.
*/
@@ -215,6 +220,7 @@ export class DocumentListViewService {
filterRules: [],
selected: new Set<number>(),
allSelected: false,
excluded: new Set<number>(),
}
}
@@ -224,7 +230,9 @@ export class DocumentListViewService {
}
this.selected.clear()
this.documents?.forEach((doc) => this.selected.add(doc.id))
this.documents
?.filter((doc) => !this.excluded.has(doc.id))
.forEach((doc) => this.selected.add(doc.id))
if (!this.collectionSize) {
this.selectNone()
@@ -491,14 +499,23 @@ export class DocumentListViewService {
return this.activeListViewState.allSelected ?? false
}
get excluded(): Set<number> {
this.trackState()
if (!this.activeListViewState.excluded) {
this.activeListViewState.excluded = new Set<number>()
}
return this.activeListViewState.excluded
}
get selectedCount(): number {
return this.allSelected
? (this.collectionSize ?? this.selected.size)
: this.selected.size
if (!this.allSelected || this.collectionSize == null) {
return this.selected.size
}
return Math.max(0, this.collectionSize - this.excluded.size)
}
get hasSelection(): boolean {
return this.allSelected || this.selected.size > 0
return this.selectedCount > 0
}
setSort(field: string, reverse: boolean) {
@@ -663,12 +680,14 @@ export class DocumentListViewService {
selectNone() {
this.activeListViewState.allSelected = false
this.selected.clear()
this.excluded.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
this.markChanged()
}
reduceSelectionToFilter() {
if (this.allSelected) {
this.excluded.clear()
return
}
@@ -688,6 +707,7 @@ export class DocumentListViewService {
selectAll() {
this.activeListViewState.allSelected = true
this.excluded.clear()
this.syncSelectedToCurrentPage()
this.markChanged()
}
@@ -695,6 +715,7 @@ export class DocumentListViewService {
selectPage() {
this.activeListViewState.allSelected = false
this.selected.clear()
this.excluded.clear()
this.documents.forEach((doc) => {
this.selected.add(doc.id)
})
@@ -702,15 +723,23 @@ export class DocumentListViewService {
}
isSelected(d: Document) {
return this.allSelected || this.selected.has(d.id)
return this.allSelected ? !this.excluded.has(d.id) : this.selected.has(d.id)
}
toggleSelected(d: Document): void {
if (this.allSelected) {
this.activeListViewState.allSelected = false
if (this.excluded.has(d.id)) {
this.excluded.delete(d.id)
this.selected.add(d.id)
} else {
this.excluded.add(d.id)
this.selected.delete(d.id)
}
} else if (this.selected.has(d.id)) {
this.selected.delete(d.id)
} else {
this.selected.add(d.id)
}
if (this.selected.has(d.id)) this.selected.delete(d.id)
else this.selected.add(d.id)
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
this.lastRangeSelectionToIndex = null
this.markChanged()
@@ -719,6 +748,7 @@ export class DocumentListViewService {
selectRangeTo(d: Document) {
if (this.allSelected) {
this.activeListViewState.allSelected = false
this.excluded.clear()
}
if (this.rangeSelectionAnchorIndex !== null) {
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
})
it('should refresh documents in place and keep unsaved edits', () => {
const openDoc = { ...documents[0] }
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
openDoc.title = 'Unsaved title'
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
openDocumentsService.refreshDocument(openDoc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
)
.flush({ ...documents[0], tags: [4] })
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
expect(refreshed).toBe(openDoc)
expect(refreshed.title).toEqual('Unsaved title')
expect(refreshed.tags).toEqual([4])
})
it('should handle error on refresh documents', () => {
subscriptions.push(
openDocumentsService.openDocument(documents[1]).subscribe()
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
if (index > -1) {
this.documentService.get(id).subscribe({
next: (doc) => {
this.openDocuments[index] = doc
const openDoc = this.openDocuments.find((d) => d.id == id)
if (!openDoc) return
const unsavedEdits = Object.fromEntries(
(openDoc.__changedFields ?? []).map((field) => [
field,
openDoc[field],
])
)
Object.assign(openDoc, doc, unsavedEdits)
this.save()
},
error: () => {
@@ -175,7 +175,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for getting selection data', () => {
const ids = [documents[0].id]
subscription = service.getSelectionData(ids).subscribe()
subscription = service.getSelectionData({ documents: ids }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/selection_data/`
)
@@ -185,6 +185,20 @@ describe(`DocumentService`, () => {
})
})
it('should get selection data with all, filters, and exclusions', () => {
const selection = {
all: true,
filters: { title__icontains: 'apple' },
excluded_documents: [2, 3],
}
subscription = service.getSelectionData(selection).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/selection_data/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual(selection)
})
it('should call appropriate api endpoint for getting suggestions', () => {
subscription = service.getSuggestions(documents[0].id).subscribe()
const req = httpTestingController.expectOne(
@@ -240,7 +254,7 @@ describe(`DocumentService`, () => {
})
})
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
it('should call appropriate api endpoint for bulk edit with all, filters, and exclusions', () => {
const method = 'modify_tags'
const parameters = {
add_tags: [15],
@@ -249,6 +263,7 @@ describe(`DocumentService`, () => {
const selection = {
all: true,
filters: { title__icontains: 'apple' },
excluded_documents: [2, 3],
}
subscription = service.bulkEdit(selection, method, parameters).subscribe()
const req = httpTestingController.expectOne(
@@ -258,6 +273,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
excluded_documents: [2, 3],
method,
parameters,
})
@@ -73,6 +73,7 @@ export interface DocumentSelectionQuery {
documents?: number[]
all?: boolean
filters?: { [key: string]: any }
excluded_documents?: number[]
}
@Injectable({
@@ -415,10 +416,12 @@ export class DocumentService extends AbstractPaperlessService<Document> {
})
}
getSelectionData(ids: number[]): Observable<SelectionData> {
getSelectionData(
selection: DocumentSelectionQuery
): Observable<SelectionData> {
return this.http.post<SelectionData>(
this.getResourceUrl(null, 'selection_data'),
{ documents: ids }
selection
)
}
@@ -48,13 +48,4 @@ describe('ShareLinkBundleService', () => {
expect(req.request.body).toEqual({})
req.flush({})
})
it('lists bundles with expected parameters', () => {
subscription = service.listAllBundles().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
)
expect(req.request.method).toBe('GET')
req.flush({ results: [] })
})
})
@@ -1,6 +1,5 @@
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import {
ShareLinkBundleCreatePayload,
ShareLinkBundleSummary,
@@ -32,10 +31,4 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
{}
)
}
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
return this.list(1, 1000, 'created', true).pipe(
map((response) => response.results)
)
}
}
@@ -14,7 +14,11 @@ import { CustomFieldDataType } from '../data/custom-field'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { SavedView } from '../data/saved-view'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
import {
HideableSidebarItemID,
SETTINGS_KEYS,
UiSettings,
} from '../data/ui-settings'
import { PermissionsService } from './permissions.service'
import { CustomFieldsService } from './rest/custom-fields.service'
import { SettingsService } from './settings.service'
@@ -163,7 +167,7 @@ describe('SettingsService', () => {
expect(settingsService.getLanguage()).toEqual('')
expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeFalsy()
expect(
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
).toBeTruthy()
expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(50)
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f')
@@ -194,17 +198,14 @@ describe('SettingsService', () => {
settingsService.setLanguage('de-de')
settingsService.set(SETTINGS_KEYS.DARK_MODE_ENABLED, true)
settingsService.set(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
false
)
settingsService.set(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS, false)
settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 25)
settingsService.set(SETTINGS_KEYS.THEME_COLOR, '#000000')
expect(settingsService.getLanguage()).toEqual('de-de')
expect(settingsService.get(SETTINGS_KEYS.DARK_MODE_ENABLED)).toBeTruthy()
expect(
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)
settingsService.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
).toBeFalsy()
expect(settingsService.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)).toEqual(25)
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
@@ -230,6 +231,35 @@ describe('SettingsService', () => {
expect(notesEnabled()).toBeFalsy()
})
it('updates sidebar item visibility', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(false)
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
false
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(true)
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
true
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(false)
})
it('updates setting signals when settings are reinitialized', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
+41 -4
View File
@@ -24,6 +24,7 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SavedView } from '../data/saved-view'
import {
HideableSidebarItemID,
PAPERLESS_GREEN_HEX,
SETTINGS,
SETTINGS_KEYS,
@@ -313,6 +314,18 @@ export class SettingsService {
readonly globalDropzoneEnabled = signal(true)
readonly globalDropzoneActive = signal(false)
readonly organizingSidebarSavedViews = signal(false)
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
null
)
readonly organizingSidebarItems = computed(
() => this.sidebarHiddenItemsEditing() !== null
)
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
HideableSidebarItemID[]
>()
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
)
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
DEFAULT_DISPLAY_FIELDS
@@ -666,7 +679,7 @@ export class SettingsService {
.subscribe({
next: () => {
this.updateAppearanceSettings()
this.toastService.showInfo(successMessage)
this.toastService.showSuccess(successMessage)
},
error: (e) => {
this.toastService.showError(errorMessage)
@@ -728,9 +741,10 @@ export class SettingsService {
this.storeSettings()
.pipe(first())
.subscribe(() => {
this.toastService.showInfo(
$localize`You can restart the tour from the settings page.`
)
this.toastService.show({
content: $localize`You can restart the tour from the settings page.`,
delay: 5000,
})
})
}
}
@@ -749,6 +763,29 @@ export class SettingsService {
return this.storeSettings()
}
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
return (
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
).includes(item)
}
updateSidebarItemVisibility(
item: HideableSidebarItemID,
visible: boolean
): void {
const hiddenItems = new Set(
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
)
if (visible) {
hiddenItems.delete(item)
} else {
hiddenItems.add(item)
}
const updatedHiddenItems = [...hiddenItems]
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
}
updateSavedViewsVisibility(
dashboardVisibleViewIds: number[],
sidebarVisibleViewIds: number[]
+90 -1
View File
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing'
import { ToastService } from './toast.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
describe('ToastService', () => {
let toastService: ToastService
@@ -68,6 +68,53 @@ describe('ToastService', () => {
})
})
it('keeps showSuccess toasts out of the toast list', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.showSuccess('Saved')
expect(popups.map((t) => t.content)).toEqual(['Saved'])
expect(toasts).toHaveLength(0)
})
it('keeps showInfo toasts out of the toast list', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.showInfo('Info toast')
toastService.showError('Error toast')
expect(popups.map((t) => t.content)).toEqual(['Info toast', 'Error toast'])
expect(toasts.map((t) => t.content)).toEqual(['Error toast'])
})
it('adds toasts to the list unless persistent is false', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.show({ content: 'kept', delay: 5000 })
toastService.show({ content: 'kept too', delay: 5000, persistent: true })
toastService.show({ content: 'popup only', delay: 5000, persistent: false })
expect(toasts.map((t) => t.content)).toEqual(['kept too', 'kept'])
})
it('still pops up non-persistent toasts while popups are suppressed', () => {
const popups: Toast[] = []
toastService.suppressPopupToasts = true
toastService.showToast.subscribe((t) => popups.push(t))
toastService.show({ content: 'kept', delay: 5000 })
toastService.showInfo('popup only')
expect(popups.map((t) => t?.content)).toEqual(['popup only'])
})
it('removes toast on close', () => {
const toast = {
title: 'Title',
@@ -106,4 +153,46 @@ describe('ToastService', () => {
toastService.suppressPopupToasts = true
toastService.showInfo('Info toast')
})
it('sets the level for showSuccess, showInfo and showError', () => {
const shown: Toast[] = []
toastService.showToast.subscribe((t) => shown.push(t))
toastService.showSuccess('Saved')
toastService.showInfo('Started')
toastService.showError('Failed')
expect(shown.map((t) => t.level)).toEqual([
ToastLevel.Success,
undefined,
ToastLevel.Error,
])
})
it('adds silent toasts to the list without a popup', () => {
let toasts: Toast[] = []
const popups: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showToast.subscribe((t) => popups.push(t))
toastService.show({ content: 'Recorded', delay: 5000, silent: true })
expect(toasts.map((t) => t.content)).toEqual(['Recorded'])
expect(popups).toHaveLength(0)
})
it('timestamps toasts and tracks read state', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
toastService.showError('Failed')
expect(toasts[0].timestamp).toBeInstanceOf(Date)
expect(toasts[0].read).toBe(false)
toastService.markAllRead()
expect(toasts[0].read).toBe(true)
// arriving while the dropdown is open counts as seen
toastService.suppressPopupToasts = true
toastService.showError('Failed again')
expect(toasts[0].read).toBe(true)
})
})
+53 -8
View File
@@ -2,6 +2,12 @@ import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
import { v4 as uuidv4 } from 'uuid'
export enum ToastLevel {
Info = 'info',
Success = 'success',
Error = 'error',
}
export interface Toast {
id?: string
@@ -9,15 +15,32 @@ export interface Toast {
delay: number
delayRemaining?: number
action?: any
actionName?: string
classname?: string
/** Defaults to ToastLevel.Info */
level?: ToastLevel
error?: any
/**
* Whether the toast is kept in the notifications dropdown after its popup
* closes. Defaults to true.
*/
persistent?: boolean
/**
* Adds the toast to the notifications dropdown without a popup, e.g. when
* the same information is already on screen. Defaults to false.
*/
silent?: boolean
/** When the toast was shown, set automatically */
timestamp?: Date
/** Whether the toast has been seen in the notifications dropdown */
read?: boolean
}
@Injectable({
@@ -42,29 +65,51 @@ export class ToastService {
if (!toast.id) {
toast.id = uuidv4()
}
toast.timestamp ??= new Date()
if (typeof toast.error === 'string') {
try {
toast.error = JSON.parse(toast.error)
} catch (e) {}
}
this.toasts.unshift(toast)
if (!this._suppressPopupToasts) {
const persistent = toast.persistent !== false
if (persistent) {
// the dropdown is open, so it's seen as it arrives
toast.read = !!this._suppressPopupToasts
this.toasts.unshift(toast)
this.toastsSubject.next(this.toasts)
}
// Popups are suppressed while the dropdown is open
if (!toast.silent && (!this._suppressPopupToasts || !persistent)) {
this.showToast.next(toast)
}
this.toastsSubject.next(this.toasts)
}
showError(content: string, error: any = null, delay: number = 10000) {
this.show({
content: content,
delay: delay,
classname: 'error',
level: ToastLevel.Error,
error,
})
}
showInfo(content: string, delay: number = 5000) {
this.show({ content: content, delay: delay })
this.show({ content: content, delay: delay, persistent: false })
}
/** For confirming that something the user did has completed */
showSuccess(content: string, delay: number = 5000) {
this.show({
content: content,
delay: delay,
level: ToastLevel.Success,
persistent: false,
})
}
markAllRead() {
this.toasts.forEach((toast) => (toast.read = true))
this.toastsSubject.next(this.toasts)
}
closeToast(toast: Toast) {
@@ -5,11 +5,15 @@ import {
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
import { DocumentListViewService } from './document-list-view.service'
import { Toast, ToastService } from './toast.service'
import { TrashService } from './trash.service'
describe('TrashService', () => {
let service: TrashService
let httpTestingController: HttpTestingController
let toastService: ToastService
let documentListViewService: DocumentListViewService
beforeEach(() => {
TestBed.configureTestingModule({
@@ -17,6 +21,8 @@ describe('TrashService', () => {
})
service = TestBed.inject(TrashService)
httpTestingController = TestBed.inject(HttpTestingController)
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
})
it('should call correct endpoint for getTrash', () => {
@@ -56,4 +62,47 @@ describe('TrashService', () => {
documents: [1, 2, 3],
})
})
it('should show a persistent moved to trash toast with undo', () => {
let toasts: Toast[] = []
toastService.getToasts().subscribe((t) => (toasts = [...t]))
const reloadSpy = jest
.spyOn(documentListViewService, 'reload')
.mockImplementation(() => {})
service.notifyMovedToTrash('Moved 2 document(s) to the trash', [1, 2])
expect(toasts).toHaveLength(1)
expect(toasts[0].actionName).toEqual('Undo')
toasts[0].action()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}trash/`
)
expect(req.request.body).toEqual({ action: 'restore', documents: [1, 2] })
req.flush('OK')
expect(reloadSpy).toHaveBeenCalled()
expect(toasts[0].content).toEqual('Document(s) restored')
})
it('should show an error if undo fails', () => {
let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0]))
const errorSpy = jest.spyOn(toastService, 'showError')
service.notifyMovedToTrash('Moved "Invoice" to the trash', [1])
toast.action()
httpTestingController
.expectOne(`${environment.apiBaseUrl}trash/`)
.flush('error', { status: 500, statusText: 'Server Error' })
expect(errorSpy).toHaveBeenCalled()
})
it('should not offer undo without document ids', () => {
let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0]))
service.notifyMovedToTrash('Moved 500 document(s) to the trash')
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
expect(toast.action).toBeUndefined()
})
})
+34
View File
@@ -4,12 +4,16 @@ import { Observable } from 'rxjs'
import { environment } from 'src/environments/environment'
import { Document } from '../data/document'
import { Results } from '../data/results'
import { DocumentListViewService } from './document-list-view.service'
import { ToastLevel, ToastService } from './toast.service'
@Injectable({
providedIn: 'root',
})
export class TrashService {
private http = inject(HttpClient)
private toastService = inject(ToastService)
private documentListViewService = inject(DocumentListViewService)
public getTrash(page: number = 1): Observable<Results<Document>> {
const httpParams = new HttpParams().set('page', page.toString())
@@ -34,4 +38,34 @@ export class TrashService {
documents,
})
}
/**
* Shows a toast with an Undo action when possible
*/
public notifyMovedToTrash(content: string, documents?: number[]) {
const canUndo = documents?.length > 0
this.toastService.show({
content,
delay: 10000,
level: ToastLevel.Success,
actionName: canUndo ? $localize`Undo` : undefined,
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined,
})
}
private undoMoveToTrash(documents: number[]) {
this.restoreDocuments(documents).subscribe({
next: () => {
this.toastService.show({
content: $localize`Document(s) restored`,
delay: 5000,
level: ToastLevel.Success,
})
this.documentListViewService.reload()
},
error: (e) => {
this.toastService.showError($localize`Error restoring document(s)`, e)
},
})
}
}
@@ -54,6 +54,31 @@ describe('UploadDocumentsService', () => {
req[0].flush('123-456')
})
it('uploads each file when given multiple files', () => {
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
const file1 = new File(['one'], 'one.pdf')
const file2 = new File(['two'], 'two.pdf')
uploadDocumentsService.uploadFiles([file1, file2])
expect(uploadSpy).toHaveBeenCalledWith(file1)
expect(uploadSpy).toHaveBeenCalledWith(file2)
httpTestingController
.match(`${environment.apiBaseUrl}documents/post_document/`)
.forEach((req) => req.flush('123-456'))
})
it('emits when uploads start', () => {
const startedSpy = jest.fn()
uploadDocumentsService.onUploadsStarted().subscribe(startedSpy)
uploadDocumentsService.uploadFiles([])
expect(startedSpy).not.toHaveBeenCalled()
const file = new File(['one'], 'one.pdf')
uploadDocumentsService.uploadFiles([file])
expect(startedSpy).toHaveBeenCalledWith([file])
httpTestingController
.match(`${environment.apiBaseUrl}documents/post_document/`)
.forEach((req) => req.flush('123-456'))
})
it('updates progress during upload and failure', () => {
const file = new File(
[new Blob(['testing'], { type: 'application/pdf' })],
@@ -1,6 +1,6 @@
import { HttpEventType } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { Subscription } from 'rxjs'
import { Subject, Subscription } from 'rxjs'
import { DocumentService } from './rest/document.service'
import {
FileStatusPhase,
@@ -16,6 +16,21 @@ export class UploadDocumentsService {
private uploadSubscriptions: Array<Subscription> = []
private readonly uploadsStartedSubject = new Subject<File[]>()
public uploadFiles(files: Iterable<File>) {
const started: File[] = []
for (const file of files) {
this.uploadFile(file)
started.push(file)
}
if (started.length) this.uploadsStartedSubject.next(started)
}
public onUploadsStarted() {
return this.uploadsStartedSubject.asObservable()
}
public uploadFile(file: File) {
let formData = new FormData()
formData.append('document', file, file.name)