mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-24 00:28:55 +00:00
Merge branch 'dev' into feature/ocr-templates
This commit is contained in:
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/`
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user