Enhancement (QoL): support deselecting single items from "select all" (#14117)

This commit is contained in:
shamoon
2026-09-18 01:28:55 +00:00
committed by GitHub
parent d80997a070
commit f4ff87e69b
9 changed files with 350 additions and 33 deletions
@@ -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
)
}