mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-21 17:08:33 +00:00
Enhancement (QoL): support deselecting single items from "select all" (#14117)
This commit is contained in:
@@ -9,7 +9,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
@@ -392,6 +392,42 @@ describe('BulkEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should request selection data for tags when documents are excluded from an all-filtered selection', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'excluded', 'get')
|
||||
.mockReturnValue(new Set([4]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||
.mockReturnValue(2)
|
||||
const adjustedSelectionData: SelectionData = {
|
||||
...selectionData,
|
||||
selected_tags: [{ id: 12, document_count: 2 }],
|
||||
}
|
||||
const getSelectionDataSpy = jest
|
||||
.spyOn(documentService, 'getSelectionData')
|
||||
.mockReturnValue(of(adjustedSelectionData))
|
||||
|
||||
component.openTagsDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).toHaveBeenCalledWith({
|
||||
all: true,
|
||||
filters: { title_search: 'apple' },
|
||||
excluded_documents: [4],
|
||||
})
|
||||
expect(component.tagDocumentCounts()).toEqual(
|
||||
adjustedSelectionData.selected_tags
|
||||
)
|
||||
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should apply list selection data to document types menu when all filtered documents are selected', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
@@ -460,6 +496,47 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should request selection data for the other metadata menus when documents are excluded', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'excluded', 'get')
|
||||
.mockReturnValue(new Set([4]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||
const getSelectionDataSpy = jest
|
||||
.spyOn(documentService, 'getSelectionData')
|
||||
.mockReturnValue(of(selectionData))
|
||||
|
||||
component.openDocumentTypeDropdown()
|
||||
component.openCorrespondentDropdown()
|
||||
component.openStoragePathDropdown()
|
||||
component.openCustomFieldsDropdown()
|
||||
|
||||
expect(getSelectionDataSpy).toHaveBeenCalledTimes(4)
|
||||
expect(getSelectionDataSpy).toHaveBeenCalledWith({
|
||||
all: true,
|
||||
filters: { title_search: 'apple' },
|
||||
excluded_documents: [4],
|
||||
})
|
||||
expect(component.documentTypeDocumentCounts()).toEqual(
|
||||
selectionData.selected_document_types
|
||||
)
|
||||
expect(component.correspondentDocumentCounts()).toEqual(
|
||||
selectionData.selected_correspondents
|
||||
)
|
||||
expect(component.storagePathDocumentCounts()).toEqual(
|
||||
selectionData.selected_storage_paths
|
||||
)
|
||||
expect(component.customFieldDocumentCounts()).toEqual(
|
||||
selectionData.selected_custom_fields
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify tags bulk operation', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
@@ -501,16 +578,19 @@ describe('BulkEditorComponent', () => {
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
.mockReturnValue(new Set([3]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'excluded', 'get')
|
||||
.mockReturnValue(new Set([4]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'filterRules', 'get')
|
||||
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selectedCount', 'get')
|
||||
.mockReturnValue(25)
|
||||
.mockReturnValue(24)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
@@ -529,6 +609,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(req.request.body).toEqual({
|
||||
all: true,
|
||||
filters: { title_search: 'apple' },
|
||||
excluded_documents: [4],
|
||||
method: 'modify_tags',
|
||||
parameters: { add_tags: [101], remove_tags: [] },
|
||||
})
|
||||
|
||||
@@ -361,6 +361,7 @@ export class BulkEditorComponent
|
||||
return {
|
||||
all: true,
|
||||
filters: queryParamsFromFilterRules(this.list.filterRules),
|
||||
excluded_documents: Array.from(this.list.excluded),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +375,8 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openTagsDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
// If none excluded, use the selection data already available in the list view, otherwise fetch
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
||||
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
||||
@@ -382,7 +384,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.tagDocumentCounts.set(s.selected_tags)
|
||||
@@ -391,7 +393,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openDocumentTypeDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.documentTypeDocumentCounts.set(
|
||||
selectionData?.selected_document_types ?? []
|
||||
@@ -404,7 +406,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
||||
@@ -416,7 +418,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openCorrespondentDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.correspondentDocumentCounts.set(
|
||||
selectionData?.selected_correspondents ?? []
|
||||
@@ -429,7 +431,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
||||
@@ -441,7 +443,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openStoragePathDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.storagePathDocumentCounts.set(
|
||||
selectionData?.selected_storage_paths ?? []
|
||||
@@ -454,7 +456,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
||||
@@ -466,7 +468,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
openCustomFieldsDropdown() {
|
||||
if (this.list.allSelected) {
|
||||
if (this.list.allSelected && this.list.excluded.size === 0) {
|
||||
const selectionData = this.list.selectionData
|
||||
this.customFieldDocumentCounts.set(
|
||||
selectionData?.selected_custom_fields ?? []
|
||||
@@ -479,7 +481,7 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.getSelectionData(this.getSelectionQuery())
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface DocumentSelectionQuery {
|
||||
documents?: number[]
|
||||
all?: boolean
|
||||
filters?: { [key: string]: any }
|
||||
excluded_documents?: number[]
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
@@ -407,10 +408,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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user