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
@@ -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
)
}