Performance: support bulk edit without id lists (#12355)

This commit is contained in:
shamoon
2026-03-31 18:23:28 +00:00
committed by GitHub
parent 2bb7c7ae17
commit d2328b776a
17 changed files with 613 additions and 123 deletions
@@ -534,12 +534,16 @@ describe('DocumentListViewService', () => {
})
it('should support select all', () => {
documentListViewService.selectAll()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
documentListViewService.reload()
const reloadReq = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
expect(reloadReq.request.method).toEqual('GET')
reloadReq.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.selectedCount).toEqual(documents.length)
expect(documentListViewService.selected.size).toEqual(documents.length)
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectNone()
@@ -575,26 +579,62 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
})
it('should support selection range reduction', () => {
it('should clear all-selected mode when toggling a single document', () => {
documentListViewService.reload()
const 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()
expect(documentListViewService.allSelected).toBeTruthy()
documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.allSelected).toBeFalsy()
expect(documentListViewService.isSelected(documents[0])).toBeFalsy()
})
it('should clear all-selected mode when selecting a range', () => {
documentListViewService.reload()
const 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[1])
documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
documentListViewService.selectRangeTo(documents[3])
expect(documentListViewService.allSelected).toBeFalsy()
expect(documentListViewService.isSelected(documents[1])).toBeTruthy()
expect(documentListViewService.isSelected(documents[2])).toBeTruthy()
expect(documentListViewService.isSelected(documents[3])).toBeTruthy()
})
it('should support selection range reduction', () => {
documentListViewService.reload()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.selected.size).toEqual(6)
documentListViewService.setFilterRules(filterRules)
httpTestingController.expectOne(
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
)
const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
)
reqs[0].flush({
req.flush({
count: 3,
results: documents.slice(0, 3),
})
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.selected.size).toEqual(3)
})
@@ -80,6 +80,11 @@ export interface ListViewState {
*/
selected?: Set<number>
/**
* True if the full filtered result set is selected.
*/
allSelected?: boolean
/**
* The page size of the list view.
*/
@@ -199,6 +204,20 @@ export class DocumentListViewService {
sortReverse: true,
filterRules: [],
selected: new Set<number>(),
allSelected: false,
}
}
private syncSelectedToCurrentPage() {
if (!this.allSelected) {
return
}
this.selected.clear()
this.documents?.forEach((doc) => this.selected.add(doc.id))
if (!this.collectionSize) {
this.selectNone()
}
}
@@ -305,6 +324,7 @@ export class DocumentListViewService {
activeListViewState.collectionSize = result.count
activeListViewState.documents = result.results
this.selectionData = resultWithSelectionData.selection_data ?? null
this.syncSelectedToCurrentPage()
if (updateQueryParams && !this._activeSavedViewId) {
let base = ['/documents']
@@ -437,6 +457,20 @@ export class DocumentListViewService {
return this.activeListViewState.selected
}
get allSelected(): boolean {
return this.activeListViewState.allSelected ?? false
}
get selectedCount(): number {
return this.allSelected
? (this.collectionSize ?? this.selected.size)
: this.selected.size
}
get hasSelection(): boolean {
return this.allSelected || this.selected.size > 0
}
setSort(field: string, reverse: boolean) {
this.activeListViewState.sortField = field
this.activeListViewState.sortReverse = reverse
@@ -591,11 +625,16 @@ export class DocumentListViewService {
}
selectNone() {
this.activeListViewState.allSelected = false
this.selected.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
}
reduceSelectionToFilter() {
if (this.allSelected) {
return
}
if (this.selected.size > 0) {
this.documentService
.listAllFilteredIds(this.filterRules)
@@ -610,12 +649,12 @@ export class DocumentListViewService {
}
selectAll() {
this.documentService
.listAllFilteredIds(this.filterRules)
.subscribe((ids) => ids.forEach((id) => this.selected.add(id)))
this.activeListViewState.allSelected = true
this.syncSelectedToCurrentPage()
}
selectPage() {
this.activeListViewState.allSelected = false
this.selected.clear()
this.documents.forEach((doc) => {
this.selected.add(doc.id)
@@ -623,10 +662,13 @@ export class DocumentListViewService {
}
isSelected(d: Document) {
return this.selected.has(d.id)
return this.allSelected || this.selected.has(d.id)
}
toggleSelected(d: Document): void {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.selected.has(d.id)) this.selected.delete(d.id)
else this.selected.add(d.id)
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
@@ -634,6 +676,10 @@ export class DocumentListViewService {
}
selectRangeTo(d: Document) {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.rangeSelectionAnchorIndex !== null) {
const documentToIndex = this.documentIndexInCurrentView(d.id)
const fromIndex = Math.min(
@@ -198,7 +198,7 @@ describe(`DocumentService`, () => {
const content = 'both'
const useFilenameFormatting = false
subscription = service
.bulkDownload(ids, content, useFilenameFormatting)
.bulkDownload({ documents: ids }, content, useFilenameFormatting)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_download/`
@@ -218,7 +218,9 @@ describe(`DocumentService`, () => {
add_tags: [15],
remove_tags: [6],
}
subscription = service.bulkEdit(ids, method, parameters).subscribe()
subscription = service
.bulkEdit({ documents: ids }, method, parameters)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
@@ -230,9 +232,32 @@ describe(`DocumentService`, () => {
})
})
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
const method = 'modify_tags'
const parameters = {
add_tags: [15],
remove_tags: [6],
}
const selection = {
all: true,
filters: { title__icontains: 'apple' },
}
subscription = service.bulkEdit(selection, method, parameters).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method,
parameters,
})
})
it('should call appropriate api endpoint for delete documents', () => {
const ids = [1, 2, 3]
subscription = service.deleteDocuments(ids).subscribe()
subscription = service.deleteDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/delete/`
)
@@ -244,7 +269,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for reprocess documents', () => {
const ids = [1, 2, 3]
subscription = service.reprocessDocuments(ids).subscribe()
subscription = service.reprocessDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/reprocess/`
)
@@ -256,7 +281,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for rotate documents', () => {
const ids = [1, 2, 3]
subscription = service.rotateDocuments(ids, 90).subscribe()
subscription = service.rotateDocuments({ documents: ids }, 90).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/rotate/`
)
@@ -68,6 +68,12 @@ export interface RemovePasswordDocumentsRequest {
source_mode?: BulkEditSourceMode
}
export interface DocumentSelectionQuery {
documents?: number[]
all?: boolean
filters?: { [key: string]: any }
}
@Injectable({
providedIn: 'root',
})
@@ -325,33 +331,37 @@ export class DocumentService extends AbstractPaperlessService<Document> {
return this.http.get<DocumentMetadata>(url.toString())
}
bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) {
bulkEdit(
selection: DocumentSelectionQuery,
method: DocumentBulkEditMethod,
args: any
) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
documents: ids,
...selection,
method: method,
parameters: args,
})
}
deleteDocuments(ids: number[]) {
deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), {
documents: ids,
...selection,
})
}
reprocessDocuments(ids: number[]) {
reprocessDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
documents: ids,
...selection,
})
}
rotateDocuments(
ids: number[],
selection: DocumentSelectionQuery,
degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) {
return this.http.post(this.getResourceUrl(null, 'rotate'), {
documents: ids,
...selection,
degrees,
source_mode: sourceMode,
})
@@ -399,14 +409,14 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}
bulkDownload(
ids: number[],
selection: DocumentSelectionQuery,
content = 'both',
useFilenameFormatting: boolean = false
) {
return this.http.post(
this.getResourceUrl(null, 'bulk_download'),
{
documents: ids,
...selection,
content: content,
follow_formatting: useFilenameFormatting,
},