Compare commits

...

18 Commits

Author SHA1 Message Date
shamoon
0bb7d755ab Sonar 2026-03-13 07:17:37 -07:00
shamoon
e4d43175af Fix 2026-03-13 07:17:36 -07:00
shamoon
04945ff3f7 Update api.md 2026-03-13 07:17:36 -07:00
shamoon
7b430e27c6 Frontend use all option for bulk edit objects instead of sending IDs 2026-03-13 07:17:00 -07:00
shamoon
b329581111 Support all for BulkEditObjectsView 2026-03-13 07:16:59 -07:00
shamoon
84e8caf25f Use a backend display_count to fix nested tag thing 2026-03-13 07:16:59 -07:00
shamoon
97602f79fb Not even optional 2026-03-13 07:16:58 -07:00
shamoon
568be982cf Remove this stuff now 2026-03-13 07:16:58 -07:00
shamoon
d753b698db tests 2026-03-13 07:16:58 -07:00
shamoon
eabd11546a Only fetch all IDs on demand 2026-03-13 07:16:57 -07:00
shamoon
43072b7a74 Backend tests 2026-03-13 07:16:57 -07:00
shamoon
1c65a1bb0e Backend deprecate all to only api v < 10 2026-03-13 07:16:56 -07:00
shamoon
0ed3103227 Update api-dashboard3.har 2026-03-13 07:16:37 -07:00
shamoon
ea55ec8bc5 Fix e2e tests 2026-03-13 07:16:37 -07:00
shamoon
c977445718 Fix 2026-03-13 07:16:36 -07:00
shamoon
b313759903 Frontend use the new integrated selection data 2026-03-13 07:16:36 -07:00
shamoon
5f0887046c Tests for include_selection_data 2026-03-13 07:16:35 -07:00
shamoon
047d4eca84 Support include_selection_data in document list/search 2026-03-13 07:16:33 -07:00
35 changed files with 768 additions and 258 deletions

View File

@@ -437,3 +437,6 @@ Initial API version.
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
the bulk edit endpoint is still supported for compatibility with versions < 10 until support the bulk edit endpoint is still supported for compatibility with versions < 10 until support
for API v9 is dropped. for API v9 is dropped.
- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
large lists of object IDs for operations affecting many objects.

View File

@@ -468,7 +468,7 @@
"time": 0.951, "time": 0.951,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=9",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

File diff suppressed because one or more lines are too long

View File

@@ -534,7 +534,7 @@
"time": 0.653, "time": 0.653,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9", "url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

View File

@@ -883,7 +883,7 @@
"time": 0.93, "time": 0.93,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [
@@ -961,7 +961,7 @@
"time": -1, "time": -1,
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4", "url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
"httpVersion": "HTTP/1.1", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [

View File

@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
await expect(page).toHaveURL(/tags__id__all=9/) await expect(page).toHaveURL(/tags__id__all=9/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Document type' }).click() await page.getByRole('button', { name: 'Document type' }).click()
await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click() await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
await expect(page).toHaveURL(/document_type__id__in=1/) await expect(page).toHaveURL(/document_type__id__in=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/) await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
await page.getByRole('button', { name: 'Reset filters' }).first().click() await page.getByRole('button', { name: 'Reset filters' }).first().click()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -20,9 +20,9 @@ import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SelectionDataItem } from 'src/app/data/results'
import { FilterPipe } from 'src/app/pipes/filter.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'

View File

@@ -300,7 +300,7 @@ describe('BulkEditorComponent', () => {
parameters: { add_tags: [101], remove_tags: [] }, parameters: { add_tags: [101], remove_tags: [] },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -332,7 +332,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -423,7 +423,7 @@ describe('BulkEditorComponent', () => {
parameters: { correspondent: 101 }, parameters: { correspondent: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -455,7 +455,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -521,7 +521,7 @@ describe('BulkEditorComponent', () => {
parameters: { document_type: 101 }, parameters: { document_type: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -553,7 +553,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -619,7 +619,7 @@ describe('BulkEditorComponent', () => {
parameters: { storage_path: 101 }, parameters: { storage_path: 101 },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -651,7 +651,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -717,7 +717,7 @@ describe('BulkEditorComponent', () => {
parameters: { add_custom_fields: [101], remove_custom_fields: [102] }, parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -749,7 +749,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`) .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true) .flush(true)
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -858,7 +858,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4], documents: [3, 4],
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -951,7 +951,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4], documents: [3, 4],
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -986,7 +986,7 @@ describe('BulkEditorComponent', () => {
source_mode: 'latest_version', source_mode: 'latest_version',
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1027,7 +1027,7 @@ describe('BulkEditorComponent', () => {
metadata_document_id: 3, metadata_document_id: 3,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1046,7 +1046,7 @@ describe('BulkEditorComponent', () => {
delete_originals: true, delete_originals: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1067,7 +1067,7 @@ describe('BulkEditorComponent', () => {
archive_fallback: true, archive_fallback: true,
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1153,7 +1153,7 @@ describe('BulkEditorComponent', () => {
}, },
}) })
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1460,7 +1460,7 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowInfoSpy).toHaveBeenCalled() expect(toastServiceShowInfoSpy).toHaveBeenCalled()
expect(listReloadSpy).toHaveBeenCalled() expect(listReloadSpy).toHaveBeenCalled()
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload ) // list reload
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`

View File

@@ -16,6 +16,7 @@ import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -32,7 +33,6 @@ import {
DocumentBulkEditMethod, DocumentBulkEditMethod,
DocumentService, DocumentService,
MergeDocumentsRequest, MergeDocumentsRequest,
SelectionDataItem,
} from 'src/app/services/rest/document.service' } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'

View File

@@ -76,6 +76,7 @@ import {
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE, NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
import { import {
PermissionAction, PermissionAction,
PermissionType, PermissionType,
@@ -84,11 +85,7 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { import { DocumentService } from 'src/app/services/rest/document.service'
DocumentService,
SelectionData,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'

View File

@@ -9,8 +9,8 @@
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (activeManagementList.selectedObjects.size > 0) { @if (activeManagementList.hasSelection) {
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
} }
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
@@ -25,7 +25,7 @@
<span class="input-group-text border-0" i18n>Select:</span> <span class="input-group-text border-0" i18n>Select:</span>
</div> </div>
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (activeManagementList.selectedObjects.size > 0) { @if (activeManagementList.hasSelection) {
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()"> <button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container> <i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button> </button>
@@ -40,11 +40,11 @@
</div> </div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()" <button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0"> [disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || !activeManagementList.hasSelection">
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container> <i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()" <button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0"> [disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || !activeManagementList.hasSelection">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()" <button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"

View File

@@ -65,8 +65,8 @@
@if (displayCollectionSize > 0) { @if (displayCollectionSize > 0) {
<div> <div>
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container> <ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
@if (selectedObjects.size > 0) { @if (hasSelection) {
&nbsp;({{selectedObjects.size}} selected) &nbsp;({{selectedCount}} selected)
} }
</div> </div>
} }

View File

@@ -117,7 +117,6 @@ describe('ManagementListComponent', () => {
: tags : tags
return of({ return of({
count: results.length, count: results.length,
all: results.map((o) => o.id),
results, results,
}) })
} }
@@ -231,11 +230,11 @@ describe('ManagementListComponent', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
}) })
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => { it('should use API count for pagination and nested ids for displayed total', fakeAsync(() => {
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce( jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
of({ of({
count: 1, count: 1,
all: [1, 2, 3], display_count: 3,
results: tags.slice(0, 1), results: tags.slice(0, 1),
}) })
) )
@@ -315,13 +314,17 @@ describe('ManagementListComponent', () => {
expect(component.togggleAll).toBe(false) expect(component.togggleAll).toBe(false)
}) })
it('selectAll should use all IDs when collection size exists', () => { it('selectAll should activate all-selection mode', () => {
;(component as any).allIDs = [1, 2, 3, 4] ;(tagService.listFiltered as jest.Mock).mockClear()
component.collectionSize = 4 component.collectionSize = tags.length
component.selectAll() component.selectAll()
expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4])) expect(tagService.listFiltered).not.toHaveBeenCalled()
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
expect((component as any).allSelectionActive).toBe(true)
expect(component.hasSelection).toBe(true)
expect(component.selectedCount).toBe(tags.length)
expect(component.togggleAll).toBe(true) expect(component.togggleAll).toBe(true)
}) })
@@ -395,6 +398,33 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled()
}) })
it('should support bulk edit permissions for all filtered items', () => {
const bulkEditPermsSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.setPermissions()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.SetPermissions,
{},
true,
true,
{ is_root: true }
)
})
it('should support bulk delete objects', () => { it('should support bulk delete objects', () => {
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects') const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
component.toggleSelected(tags[0]) component.toggleSelected(tags[0])
@@ -415,7 +445,11 @@ describe('ManagementListComponent', () => {
modal.componentInstance.confirmClicked.emit(null) modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith( expect(bulkEditSpy).toHaveBeenCalledWith(
Array.from(selected), Array.from(selected),
BulkEditObjectOperation.Delete BulkEditObjectOperation.Delete,
null,
null,
false,
null
) )
expect(errorToastSpy).toHaveBeenCalled() expect(errorToastSpy).toHaveBeenCalled()
@@ -426,6 +460,29 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled() expect(successToastSpy).toHaveBeenCalled()
}) })
it('should support bulk delete for all filtered items', () => {
const bulkEditSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.delete()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ is_root: true }
)
})
it('should disallow bulk permissions or delete objects if no global perms', () => { it('should disallow bulk permissions or delete objects if no global perms', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy() expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()

View File

@@ -90,7 +90,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public data: T[] = [] public data: T[] = []
private unfilteredData: T[] = [] private unfilteredData: T[] = []
private allIDs: number[] = [] private currentExtraParams: { [key: string]: any } = null
private allSelectionActive = false
public page = 1 public page = 1
@@ -107,6 +108,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public selectedObjects: Set<number> = new Set() public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false public togggleAll: boolean = false
public get hasSelection(): boolean {
return this.selectedObjects.size > 0 || this.allSelectionActive
}
public get selectedCount(): number {
return this.allSelectionActive
? this.displayCollectionSize
: this.selectedObjects.size
}
ngOnInit(): void { ngOnInit(): void {
this.reloadData() this.reloadData()
@@ -150,11 +161,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
protected getCollectionSize(results: Results<T>): number { protected getCollectionSize(results: Results<T>): number {
return results.all?.length ?? results.count return results.count
} }
protected getDisplayCollectionSize(results: Results<T>): number { protected getDisplayCollectionSize(results: Results<T>): number {
return this.getCollectionSize(results) return results.display_count ?? this.getCollectionSize(results)
} }
getDocumentCount(object: MatchingModel): number { getDocumentCount(object: MatchingModel): number {
@@ -171,6 +182,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
reloadData(extraParams: { [key: string]: any } = null) { reloadData(extraParams: { [key: string]: any } = null) {
this.loading = true this.loading = true
this.currentExtraParams = extraParams
this.clearSelection() this.clearSelection()
this.service this.service
.listFiltered( .listFiltered(
@@ -189,7 +201,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.data = this.filterData(c.results) this.data = this.filterData(c.results)
this.collectionSize = this.getCollectionSize(c) this.collectionSize = this.getCollectionSize(c)
this.displayCollectionSize = this.getDisplayCollectionSize(c) this.displayCollectionSize = this.getDisplayCollectionSize(c)
this.allIDs = c.all
}), }),
delay(100) delay(100)
) )
@@ -346,7 +357,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
return objects.map((o) => o.id) return objects.map((o) => o.id)
} }
private getBulkEditFilters(): { [key: string]: any } {
const filters = { ...this.currentExtraParams }
if (this._nameFilter?.length) {
filters['name__icontains'] = this._nameFilter
}
return filters
}
clearSelection() { clearSelection() {
this.allSelectionActive = false
this.togggleAll = false this.togggleAll = false
this.selectedObjects.clear() this.selectedObjects.clear()
} }
@@ -356,6 +376,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
selectPage() { selectPage() {
this.allSelectionActive = false
this.selectedObjects = new Set(this.getSelectableIDs(this.data)) this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} }
@@ -365,11 +386,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.clearSelection() this.clearSelection()
return return
} }
this.selectedObjects = new Set(this.allIDs)
this.allSelectionActive = true
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} }
toggleSelected(object) { toggleSelected(object) {
if (this.allSelectionActive) {
this.allSelectionActive = false
}
this.selectedObjects.has(object.id) this.selectedObjects.has(object.id)
? this.selectedObjects.delete(object.id) ? this.selectedObjects.delete(object.id)
: this.selectedObjects.add(object.id) : this.selectedObjects.add(object.id)
@@ -377,6 +403,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
protected areAllPageItemsSelected(): boolean { protected areAllPageItemsSelected(): boolean {
if (this.allSelectionActive) {
return this.data.length > 0
}
const ids = this.getSelectableIDs(this.data) const ids = this.getSelectableIDs(this.data)
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id)) return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
} }
@@ -390,10 +419,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_edit_objects( .bulk_edit_objects(
Array.from(this.selectedObjects), this.allSelectionActive ? [] : Array.from(this.selectedObjects),
BulkEditObjectOperation.SetPermissions, BulkEditObjectOperation.SetPermissions,
permissions, permissions,
merge merge,
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -428,8 +459,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_edit_objects( .bulk_edit_objects(
Array.from(this.selectedObjects), this.allSelectionActive ? [] : Array.from(this.selectedObjects),
BulkEditObjectOperation.Delete BulkEditObjectOperation.Delete,
null,
null,
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
) )
.subscribe({ .subscribe({
next: () => { next: () => {

View File

@@ -41,7 +41,6 @@ describe('TagListComponent', () => {
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue( listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
of({ of({
count: 3, count: 3,
all: [1, 2, 3],
results: [ results: [
{ {
id: 1, id: 1,

View File

@@ -9,7 +9,6 @@ import {
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
@@ -77,16 +76,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent)) return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
} }
protected override getCollectionSize(results: Results<Tag>): number {
// Tag list pages are requested with is_root=true (when unfiltered), so
// pagination must follow root count even though `all` includes descendants
return results.count
}
protected override getDisplayCollectionSize(results: Results<Tag>): number {
return super.getCollectionSize(results)
}
protected override getSelectableIDs(tags: Tag[]): number[] { protected override getSelectableIDs(tags: Tag[]): number[] {
const ids: number[] = [] const ids: number[] = []
for (const tag of tags.filter(Boolean)) { for (const tag of tags.filter(Boolean)) {

View File

@@ -1,7 +1,26 @@
import { Document } from './document'
export interface Results<T> { export interface Results<T> {
count: number count: number
results: T[] display_count?: number
all: number[] results: T[]
}
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export interface DocumentResults extends Results<Document> {
selection_data?: SelectionData
} }

View File

@@ -126,13 +126,10 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.isReloading).toBeFalsy() expect(documentListViewService.isReloading).toBeFalsy()
expect(documentListViewService.activeSavedViewId).toBeNull() expect(documentListViewService.activeSavedViewId).toBeNull()
@@ -144,12 +141,12 @@ describe('DocumentListViewService', () => {
it('should handle error on page request out of range', () => { it('should handle error on page request out of range', () => {
documentListViewService.currentPage = 50 documentListViewService.currentPage = 50
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush([], { status: 404, statusText: 'Unexpected error' }) req.flush([], { status: 404, statusText: 'Unexpected error' })
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
@@ -166,7 +163,7 @@ describe('DocumentListViewService', () => {
] ]
documentListViewService.setFilterRules(filterRulesAny) documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush( req.flush(
@@ -174,13 +171,13 @@ describe('DocumentListViewService', () => {
{ status: 404, statusText: 'Unexpected error' } { status: 404, statusText: 'Unexpected error' }
) )
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -188,7 +185,7 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 1 documentListViewService.currentPage = 1
documentListViewService.sortField = 'custom_field_999' documentListViewService.sortField = 'custom_field_999'
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush( req.flush(
@@ -197,7 +194,7 @@ describe('DocumentListViewService', () => {
) )
// resets itself // resets itself
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -212,7 +209,7 @@ describe('DocumentListViewService', () => {
] ]
documentListViewService.setFilterRules(filterRulesAny) documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' }) req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
@@ -220,7 +217,7 @@ describe('DocumentListViewService', () => {
// reset the list // reset the list
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -229,7 +226,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.sortReverse).toBeTruthy() expect(documentListViewService.sortReverse).toBeTruthy()
documentListViewService.setSort('added', false) documentListViewService.setSort('added', false)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortField).toEqual('added') expect(documentListViewService.sortField).toEqual('added')
@@ -237,12 +234,12 @@ describe('DocumentListViewService', () => {
documentListViewService.sortField = 'created' documentListViewService.sortField = 'created'
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=true`
) )
expect(documentListViewService.sortField).toEqual('created') expect(documentListViewService.sortField).toEqual('created')
documentListViewService.sortReverse = true documentListViewService.sortReverse = true
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortReverse).toBeTruthy() expect(documentListViewService.sortReverse).toBeTruthy()
@@ -262,7 +259,7 @@ describe('DocumentListViewService', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${ `${environment.apiBaseUrl}documents/?page=${page}&page_size=${
documentListViewService.pageSize documentListViewService.pageSize
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true` }&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(page) expect(documentListViewService.currentPage).toEqual(page)
@@ -279,7 +276,7 @@ describe('DocumentListViewService', () => {
} }
documentListViewService.loadFromQueryParams(convertToParamMap(params)) documentListViewService.loadFromQueryParams(convertToParamMap(params))
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.filterRules).toEqual([ expect(documentListViewService.filterRules).toEqual([
@@ -289,15 +286,12 @@ describe('DocumentListViewService', () => {
}, },
]) ])
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
}) })
it('should use filter rules to update query params', () => { it('should use filter rules to update query params', () => {
documentListViewService.setFilterRules(filterRules) documentListViewService.setFilterRules(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -306,34 +300,26 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 2 documentListViewService.currentPage = 2
let req = httpTestingController.expectOne((request) => let req = httpTestingController.expectOne((request) =>
request.urlWithParams.startsWith( request.urlWithParams.startsWith(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
documentListViewService.setFilterRules(filterRules, true) documentListViewService.setFilterRules(filterRules, true)
const filteredReqs = httpTestingController.match( const filteredReqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(filteredReqs).toHaveLength(1) expect(filteredReqs).toHaveLength(1)
filteredReqs[0].flush(full_results) filteredReqs[0].flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
}) })
it('should support quick filter', () => { it('should support quick filter', () => {
documentListViewService.quickFilter(filterRules) documentListViewService.quickFilter(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -356,21 +342,21 @@ describe('DocumentListViewService', () => {
convertToParamMap(params) convertToParamMap(params)
) )
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
documentListViewService.currentPage = 1 documentListViewService.currentPage = 1
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9`
) )
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true`
) )
documentListViewService.sortField = 'created' documentListViewService.sortField = 'created'
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
documentListViewService.activateSavedView(null) documentListViewService.activateSavedView(null)
}) })
@@ -378,21 +364,18 @@ describe('DocumentListViewService', () => {
it('should support navigating next / previous', () => { it('should support navigating next / previous', () => {
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ req.flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
.flush([])
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy() expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy() expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
documentListViewService.getNext(documents[0].id).subscribe((docId) => { documentListViewService.getNext(documents[0].id).subscribe((docId) => {
@@ -439,7 +422,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
jest jest
.spyOn(documentListViewService, 'getLastPage') .spyOn(documentListViewService, 'getLastPage')
@@ -454,7 +437,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(2) expect(documentListViewService.currentPage).toEqual(2)
const reqs = httpTestingController.match( const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(reqs.length).toBeGreaterThan(0) expect(reqs.length).toBeGreaterThan(0)
}) })
@@ -489,11 +472,11 @@ describe('DocumentListViewService', () => {
.mockReturnValue(documents) .mockReturnValue(documents)
documentListViewService.currentPage = 2 documentListViewService.currentPage = 2
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
const reloadSpy = jest.spyOn(documentListViewService, 'reload') const reloadSpy = jest.spyOn(documentListViewService, 'reload')
documentListViewService.getPrevious(1).subscribe({ documentListViewService.getPrevious(1).subscribe({
@@ -503,7 +486,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
const reqs = httpTestingController.match( const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(reqs.length).toBeGreaterThan(0) expect(reqs.length).toBeGreaterThan(0)
}) })
@@ -516,13 +499,10 @@ describe('DocumentListViewService', () => {
it('should support select a document', () => { it('should support select a document', () => {
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
@@ -544,16 +524,13 @@ describe('DocumentListViewService', () => {
it('should support select page', () => { it('should support select page', () => {
documentListViewService.pageSize = 3 documentListViewService.pageSize = 3
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ req.flush({
count: 3, count: 3,
results: documents.slice(0, 3), results: documents.slice(0, 3),
}) })
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.selectPage() documentListViewService.selectPage()
expect(documentListViewService.selected.size).toEqual(3) expect(documentListViewService.selected.size).toEqual(3)
expect(documentListViewService.isSelected(documents[5])).toBeFalsy() expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
@@ -562,13 +539,10 @@ describe('DocumentListViewService', () => {
it('should support select range', () => { it('should support select range', () => {
documentListViewService.reload() documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush(full_results) req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0]) documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy() expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectRangeTo(documents[2]) documentListViewService.selectRangeTo(documents[2])
@@ -588,7 +562,7 @@ describe('DocumentListViewService', () => {
documentListViewService.setFilterRules(filterRules) documentListViewService.setFilterRules(filterRules)
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9` `${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( const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
@@ -604,7 +578,7 @@ describe('DocumentListViewService', () => {
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending') const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
documentListViewService.reload() documentListViewService.reload()
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
) )
expect(cancelSpy).toHaveBeenCalled() expect(cancelSpy).toHaveBeenCalled()
}) })
@@ -623,7 +597,7 @@ describe('DocumentListViewService', () => {
documentListViewService.setFilterRules([]) documentListViewService.setFilterRules([])
expect(documentListViewService.sortField).toEqual('created') expect(documentListViewService.sortField).toEqual('created')
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
}) })
@@ -650,11 +624,11 @@ describe('DocumentListViewService', () => {
expect(localStorageSpy).toHaveBeenCalled() expect(localStorageSpy).toHaveBeenCalled()
// reload triggered // reload triggered
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
documentListViewService.displayFields = null documentListViewService.displayFields = null
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
expect(documentListViewService.displayFields).toEqual( expect(documentListViewService.displayFields).toEqual(
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map( DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
@@ -694,7 +668,7 @@ describe('DocumentListViewService', () => {
it('should generate quick filter URL preserving default state', () => { it('should generate quick filter URL preserving default state', () => {
documentListViewService.reload() documentListViewService.reload()
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) )
const urlTree = documentListViewService.getQuickFilterUrl(filterRules) const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
expect(urlTree).toBeDefined() expect(urlTree).toBeDefined()

View File

@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { ParamMap, Router, UrlTree } from '@angular/router' import { ParamMap, Router, UrlTree } from '@angular/router'
import { Observable, Subject, first, takeUntil } from 'rxjs' import { Observable, Subject, takeUntil } from 'rxjs'
import { import {
DEFAULT_DISPLAY_FIELDS, DEFAULT_DISPLAY_FIELDS,
DisplayField, DisplayField,
@@ -8,6 +8,7 @@ import {
Document, Document,
} from '../data/document' } from '../data/document'
import { FilterRule } from '../data/filter-rule' import { FilterRule } from '../data/filter-rule'
import { DocumentResults, SelectionData } from '../data/results'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { SETTINGS_KEYS } from '../data/ui-settings' import { SETTINGS_KEYS } from '../data/ui-settings'
@@ -17,7 +18,7 @@ import {
isFullTextFilterRule, isFullTextFilterRule,
} from '../utils/filter-rules' } from '../utils/filter-rules'
import { paramsFromViewState, paramsToViewState } from '../utils/query-params' import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
import { DocumentService, SelectionData } from './rest/document.service' import { DocumentService } from './rest/document.service'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map( const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
@@ -260,27 +261,17 @@ export class DocumentListViewService {
activeListViewState.sortField, activeListViewState.sortField,
activeListViewState.sortReverse, activeListViewState.sortReverse,
activeListViewState.filterRules, activeListViewState.filterRules,
{ truncate_content: true } { truncate_content: true, include_selection_data: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
const resultWithSelectionData = result as DocumentResults
this.initialized = true this.initialized = true
this.isReloading = false this.isReloading = false
activeListViewState.collectionSize = result.count activeListViewState.collectionSize = result.count
activeListViewState.documents = result.results activeListViewState.documents = result.results
this.selectionData = resultWithSelectionData.selection_data ?? null
this.documentService
.getSelectionData(result.all)
.pipe(first())
.subscribe({
next: (selectionData) => {
this.selectionData = selectionData
},
error: () => {
this.selectionData = null
},
})
if (updateQueryParams && !this._activeSavedViewId) { if (updateQueryParams && !this._activeSavedViewId) {
let base = ['/documents'] let base = ['/documents']

View File

@@ -96,6 +96,30 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
}) })
req.flush([]) req.flush([])
}) })
test('should call appropriate api endpoint for bulk delete on all filtered objects', () => {
subscription = service
.bulk_edit_objects(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ name__icontains: 'hello' }
)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_objects/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
object_type: endpoint,
operation: BulkEditObjectOperation.Delete,
all: true,
filters: { name__icontains: 'hello' },
})
req.flush([])
})
}) })
beforeEach(() => { beforeEach(() => {

View File

@@ -37,13 +37,22 @@ export abstract class AbstractNameFilterService<
objects: Array<number>, objects: Array<number>,
operation: BulkEditObjectOperation, operation: BulkEditObjectOperation,
permissions: { owner: number; set_permissions: PermissionsObject } = null, permissions: { owner: number; set_permissions: PermissionsObject } = null,
merge: boolean = null merge: boolean = null,
all: boolean = false,
filters: { [key: string]: any } = null
): Observable<string> { ): Observable<string> {
const params = { const params: any = {
objects,
object_type: this.resourceName, object_type: this.resourceName,
operation, operation,
} }
if (all) {
params['all'] = true
if (filters) {
params['filters'] = filters
}
} else {
params['objects'] = objects
}
if (operation === BulkEditObjectOperation.SetPermissions) { if (operation === BulkEditObjectOperation.SetPermissions) {
params['owner'] = permissions?.owner params['owner'] = permissions?.owner
params['permissions'] = permissions?.set_permissions params['permissions'] = permissions?.set_permissions

View File

@@ -12,7 +12,7 @@ import {
import { DocumentMetadata } from 'src/app/data/document-metadata' import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions' import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { Results } from 'src/app/data/results' import { Results, SelectionData } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { queryParamsFromFilterRules } from '../../utils/query-params' import { queryParamsFromFilterRules } from '../../utils/query-params'
import { import {
@@ -24,19 +24,6 @@ import { SettingsService } from '../settings.service'
import { AbstractPaperlessService } from './abstract-paperless-service' import { AbstractPaperlessService } from './abstract-paperless-service'
import { CustomFieldsService } from './custom-fields.service' import { CustomFieldsService } from './custom-fields.service'
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export enum BulkEditSourceMode { export enum BulkEditSourceMode {
LATEST_VERSION = 'latest_version', LATEST_VERSION = 'latest_version',
EXPLICIT_SELECTION = 'explicit_selection', EXPLICIT_SELECTION = 'explicit_selection',

View File

@@ -375,6 +375,26 @@ class DelayedQuery:
] ]
return self._manual_hits_cache return self._manual_hits_cache
def get_result_ids(self) -> list[int]:
"""
Return all matching document IDs for the current query and ordering.
"""
if self._manual_sort_requested():
return [hit["id"] for hit in self._manual_hits()]
q, mask, suggested_correction = self._get_query()
self.suggested_correction = suggested_correction
sortedby, reverse = self._get_query_sortedby()
results = self.searcher.search(
q,
mask=mask,
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
limit=None,
sortedby=sortedby,
reverse=reverse,
)
return [hit["id"] for hit in results]
def __getitem__(self, item): def __getitem__(self, item):
if item.start in self.saved_results: if item.start in self.saved_results:
return self.saved_results[item.start] return self.saved_results[item.start]

View File

@@ -2571,13 +2571,25 @@ class ShareLinkBundleSerializer(OwnedObjectSerializer):
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin): class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
objects = serializers.ListField( objects = serializers.ListField(
required=True, required=False,
allow_empty=False, allow_empty=True,
label="Objects", label="Objects",
write_only=True, write_only=True,
child=serializers.IntegerField(), child=serializers.IntegerField(),
) )
all = serializers.BooleanField(
default=False,
required=False,
write_only=True,
)
filters = serializers.DictField(
required=False,
allow_empty=True,
write_only=True,
)
object_type = serializers.ChoiceField( object_type = serializers.ChoiceField(
choices=[ choices=[
"tags", "tags",
@@ -2650,10 +2662,20 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
def validate(self, attrs): def validate(self, attrs):
object_type = attrs["object_type"] object_type = attrs["object_type"]
objects = attrs["objects"] objects = attrs.get("objects")
apply_to_all = attrs.get("all", False)
operation = attrs.get("operation") operation = attrs.get("operation")
self._validate_objects(objects, object_type) if apply_to_all:
attrs.setdefault("objects", [])
else:
if objects is None:
raise serializers.ValidationError(
"objects is required unless all is true.",
)
if len(objects) == 0:
raise serializers.ValidationError("objects must not be empty")
self._validate_objects(objects, object_type)
if operation == "set_permissions": if operation == "set_permissions":
permissions = attrs.get("permissions") permissions = attrs.get("permissions")

View File

@@ -1119,21 +1119,19 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
[u1_doc1.id], [u1_doc1.id],
) )
def test_pagination_all(self) -> None: def test_pagination_results(self) -> None:
""" """
GIVEN: GIVEN:
- A set of 50 documents - A set of 50 documents
WHEN: WHEN:
- API request for document filtering - API request for document filtering
THEN: THEN:
- Results are paginated (25 items) and response["all"] returns all ids (50 items) - Results are paginated (25 items) and count reflects all results (50 items)
""" """
t = Tag.objects.create(name="tag") t = Tag.objects.create(name="tag")
docs = []
for i in range(50): for i in range(50):
d = Document.objects.create(checksum=i, content=f"test{i}") d = Document.objects.create(checksum=i, content=f"test{i}")
d.tags.add(t) d.tags.add(t)
docs.append(d)
response = self.client.get( response = self.client.get(
f"/api/documents/?tags__id__in={t.id}", f"/api/documents/?tags__id__in={t.id}",
@@ -1141,9 +1139,84 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"] results = response.data["results"]
self.assertEqual(len(results), 25) self.assertEqual(len(results), 25)
self.assertEqual(len(response.data["all"]), 50) self.assertEqual(response.data["count"], 50)
self.assertNotIn("all", response.data)
def test_pagination_all_for_api_version_9(self) -> None:
"""
GIVEN:
- A set of documents matching a filter
WHEN:
- API request uses legacy version 9
THEN:
- Response includes "all" for backward compatibility
"""
t = Tag.objects.create(name="tag")
docs = []
for i in range(4):
d = Document.objects.create(checksum=i, content=f"test{i}")
d.tags.add(t)
docs.append(d)
response = self.client.get(
f"/api/documents/?tags__id__in={t.id}",
headers={"Accept": "application/json; version=9"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("all", response.data)
self.assertCountEqual(response.data["all"], [d.id for d in docs]) self.assertCountEqual(response.data["all"], [d.id for d in docs])
def test_list_with_include_selection_data(self) -> None:
correspondent = Correspondent.objects.create(name="c1")
doc_type = DocumentType.objects.create(name="dt1")
storage_path = StoragePath.objects.create(name="sp1")
tag = Tag.objects.create(name="tag")
matching_doc = Document.objects.create(
checksum="A",
correspondent=correspondent,
document_type=doc_type,
storage_path=storage_path,
)
matching_doc.tags.add(tag)
non_matching_doc = Document.objects.create(checksum="B")
non_matching_doc.tags.add(Tag.objects.create(name="other"))
response = self.client.get(
f"/api/documents/?tags__id__in={tag.id}&include_selection_data=true",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("selection_data", response.data)
selected_correspondent = next(
item
for item in response.data["selection_data"]["selected_correspondents"]
if item["id"] == correspondent.id
)
selected_tag = next(
item
for item in response.data["selection_data"]["selected_tags"]
if item["id"] == tag.id
)
selected_type = next(
item
for item in response.data["selection_data"]["selected_document_types"]
if item["id"] == doc_type.id
)
selected_storage_path = next(
item
for item in response.data["selection_data"]["selected_storage_paths"]
if item["id"] == storage_path.id
)
self.assertEqual(selected_correspondent["document_count"], 1)
self.assertEqual(selected_tag["document_count"], 1)
self.assertEqual(selected_type["document_count"], 1)
self.assertEqual(selected_storage_path["document_count"], 1)
def test_statistics(self) -> None: def test_statistics(self) -> None:
doc1 = Document.objects.create( doc1 = Document.objects.create(
title="none1", title="none1",

View File

@@ -145,6 +145,22 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
response.data["last_correspondence"], response.data["last_correspondence"],
) )
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
response_v10 = self.client.get("/api/correspondents/")
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
self.assertNotIn("all", response_v10.data)
response_v9 = self.client.get(
"/api/correspondents/",
headers={"Accept": "application/json; version=9"},
)
self.assertEqual(response_v9.status_code, status.HTTP_200_OK)
self.assertIn("all", response_v9.data)
self.assertCountEqual(
response_v9.data["all"],
[self.c1.id, self.c2.id, self.c3.id],
)
class TestApiStoragePaths(DirectoriesMixin, APITestCase): class TestApiStoragePaths(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/storage_paths/" ENDPOINT = "/api/storage_paths/"
@@ -774,6 +790,62 @@ class TestBulkEditObjects(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoragePath.objects.count(), 0) self.assertEqual(StoragePath.objects.count(), 0)
def test_bulk_objects_delete_all_filtered(self) -> None:
"""
GIVEN:
- Existing objects that can be filtered by name
WHEN:
- bulk_edit_objects API endpoint is called with all=true and filters
THEN:
- Matching objects are deleted without passing explicit IDs
"""
Correspondent.objects.create(name="c2")
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"all": True,
"filters": {"name__icontains": "c"},
"object_type": "correspondents",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Correspondent.objects.count(), 0)
def test_bulk_objects_delete_all_filtered_tags_includes_descendants(self) -> None:
"""
GIVEN:
- Root tag with descendants
WHEN:
- bulk_edit_objects API endpoint is called with all=true
THEN:
- Root tags and descendants are deleted
"""
parent = Tag.objects.create(name="parent")
child = Tag.objects.create(name="child", tn_parent=parent)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"all": True,
"filters": {"is_root": True},
"object_type": "tags",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(Tag.objects.filter(id=parent.id).exists())
self.assertFalse(Tag.objects.filter(id=child.id).exists())
def test_bulk_edit_object_permissions_insufficient_global_perms(self) -> None: def test_bulk_edit_object_permissions_insufficient_global_perms(self) -> None:
""" """
GIVEN: GIVEN:
@@ -861,3 +933,40 @@ class TestBulkEditObjects(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions") self.assertEqual(response.content, b"Insufficient permissions")
def test_bulk_edit_all_filtered_permissions_insufficient_object_perms(
self,
) -> None:
"""
GIVEN:
- Filter-matching objects include one that the user cannot edit
WHEN:
- bulk_edit_objects API endpoint is called with all=true
THEN:
- Operation applies only to editable objects
"""
self.t2.owner = User.objects.get(username="temp_admin")
self.t2.save()
self.user1.user_permissions.add(
*Permission.objects.filter(codename="delete_tag"),
)
self.user1.save()
self.client.force_authenticate(user=self.user1)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"all": True,
"filters": {"name__icontains": "t"},
"object_type": "tags",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(Tag.objects.filter(id=self.t2.id).exists())
self.assertFalse(Tag.objects.filter(id=self.t1.id).exists())

View File

@@ -68,26 +68,88 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
results = response.data["results"] results = response.data["results"]
self.assertEqual(response.data["count"], 3) self.assertEqual(response.data["count"], 3)
self.assertEqual(len(results), 3) self.assertEqual(len(results), 3)
self.assertCountEqual(response.data["all"], [d1.id, d2.id, d3.id])
response = self.client.get("/api/documents/?query=september") response = self.client.get("/api/documents/?query=september")
results = response.data["results"] results = response.data["results"]
self.assertEqual(response.data["count"], 1) self.assertEqual(response.data["count"], 1)
self.assertEqual(len(results), 1) self.assertEqual(len(results), 1)
self.assertCountEqual(response.data["all"], [d3.id])
self.assertEqual(results[0]["original_file_name"], "someepdf.pdf") self.assertEqual(results[0]["original_file_name"], "someepdf.pdf")
response = self.client.get("/api/documents/?query=statement") response = self.client.get("/api/documents/?query=statement")
results = response.data["results"] results = response.data["results"]
self.assertEqual(response.data["count"], 2) self.assertEqual(response.data["count"], 2)
self.assertEqual(len(results), 2) self.assertEqual(len(results), 2)
self.assertCountEqual(response.data["all"], [d2.id, d3.id])
response = self.client.get("/api/documents/?query=sfegdfg") response = self.client.get("/api/documents/?query=sfegdfg")
results = response.data["results"] results = response.data["results"]
self.assertEqual(response.data["count"], 0) self.assertEqual(response.data["count"], 0)
self.assertEqual(len(results), 0) self.assertEqual(len(results), 0)
self.assertCountEqual(response.data["all"], [])
def test_search_returns_all_for_api_version_9(self) -> None:
d1 = Document.objects.create(
title="invoice",
content="bank payment",
checksum="A",
pk=1,
)
d2 = Document.objects.create(
title="bank statement",
content="bank transfer",
checksum="B",
pk=2,
)
with AsyncWriter(index.open_index()) as writer:
index.update_document(writer, d1)
index.update_document(writer, d2)
response = self.client.get(
"/api/documents/?query=bank",
headers={"Accept": "application/json; version=9"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("all", response.data)
self.assertCountEqual(response.data["all"], [d1.id, d2.id])
def test_search_with_include_selection_data(self) -> None:
correspondent = Correspondent.objects.create(name="c1")
doc_type = DocumentType.objects.create(name="dt1")
storage_path = StoragePath.objects.create(name="sp1")
tag = Tag.objects.create(name="tag")
matching_doc = Document.objects.create(
title="bank statement",
content="bank content",
checksum="A",
correspondent=correspondent,
document_type=doc_type,
storage_path=storage_path,
)
matching_doc.tags.add(tag)
with AsyncWriter(index.open_index()) as writer:
index.update_document(writer, matching_doc)
response = self.client.get(
"/api/documents/?query=bank&include_selection_data=true",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("selection_data", response.data)
selected_correspondent = next(
item
for item in response.data["selection_data"]["selected_correspondents"]
if item["id"] == correspondent.id
)
selected_tag = next(
item
for item in response.data["selection_data"]["selected_tags"]
if item["id"] == tag.id
)
self.assertEqual(selected_correspondent["document_count"], 1)
self.assertEqual(selected_tag["document_count"], 1)
def test_search_custom_field_ordering(self) -> None: def test_search_custom_field_ordering(self) -> None:
custom_field = CustomField.objects.create( custom_field = CustomField.objects.create(

View File

@@ -555,7 +555,9 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
serializer = self.get_serializer(page, many=True) serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data) response = self.get_paginated_response(serializer.data)
if descendant_pks: response.data["display_count"] = len(children_source)
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
if descendant_pks and api_version < 10:
# Include children in the "all" field, if needed # Include children in the "all" field, if needed
response.data["all"] = [tag.pk for tag in children_source] response.data["all"] = [tag.pk for tag in children_source]
return response return response
@@ -835,6 +837,61 @@ class DocumentViewSet(
"custom_field_", "custom_field_",
) )
def _get_selection_data_for_queryset(self, queryset):
correspondents = Correspondent.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
tags = Tag.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
document_types = DocumentType.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
storage_paths = StoragePath.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
custom_fields = CustomField.objects.annotate(
document_count=Count(
"fields__document",
filter=Q(fields__document__in=queryset),
distinct=True,
),
)
return {
"selected_correspondents": [
{"id": t.id, "document_count": t.document_count} for t in correspondents
],
"selected_tags": [
{"id": t.id, "document_count": t.document_count} for t in tags
],
"selected_document_types": [
{"id": t.id, "document_count": t.document_count} for t in document_types
],
"selected_storage_paths": [
{"id": t.id, "document_count": t.document_count} for t in storage_paths
],
"selected_custom_fields": [
{"id": t.id, "document_count": t.document_count} for t in custom_fields
],
}
def get_queryset(self): def get_queryset(self):
latest_version_content = Subquery( latest_version_content = Subquery(
Document.objects.filter(root_document=OuterRef("pk")) Document.objects.filter(root_document=OuterRef("pk"))
@@ -982,6 +1039,25 @@ class DocumentViewSet(
return response return response
def list(self, request, *args, **kwargs):
if not get_boolean(
str(request.query_params.get("include_selection_data", "false")),
):
return super().list(request, *args, **kwargs)
queryset = self.filter_queryset(self.get_queryset())
selection_data = self._get_selection_data_for_queryset(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
response.data["selection_data"] = selection_data
return response
serializer = self.get_serializer(queryset, many=True)
return Response({"results": serializer.data, "selection_data": selection_data})
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
from documents import index from documents import index
@@ -2002,6 +2078,21 @@ class UnifiedSearchViewSet(DocumentViewSet):
else None else None
) )
if get_boolean(
str(
request.query_params.get(
"include_selection_data",
"false",
),
),
):
result_ids = queryset.get_result_ids()
response.data["selection_data"] = (
self._get_selection_data_for_queryset(
Document.objects.filter(pk__in=result_ids),
)
)
return response return response
except NotFound: except NotFound:
raise raise
@@ -3790,20 +3881,55 @@ class BulkEditObjectsView(PassUserMixin):
user = self.request.user user = self.request.user
object_type = serializer.validated_data.get("object_type") object_type = serializer.validated_data.get("object_type")
object_ids = serializer.validated_data.get("objects") object_ids = serializer.validated_data.get("objects")
apply_to_all = serializer.validated_data.get("all")
object_class = serializer.get_object_class(object_type) object_class = serializer.get_object_class(object_type)
operation = serializer.validated_data.get("operation") operation = serializer.validated_data.get("operation")
model_name = object_class._meta.model_name
perm_codename = (
f"change_{model_name}"
if operation == "set_permissions"
else f"delete_{model_name}"
)
objs = object_class.objects.select_related("owner").filter(pk__in=object_ids) if apply_to_all:
# Support all to avoid sending large lists of ids for bulk operations, with optional filters
filters = serializer.validated_data.get("filters") or {}
filterset_class = {
"tags": TagFilterSet,
"correspondents": CorrespondentFilterSet,
"document_types": DocumentTypeFilterSet,
"storage_paths": StoragePathFilterSet,
}[object_type]
user_permitted_objects = get_objects_for_user_owner_aware(
user,
perm_codename,
object_class,
)
objs = filterset_class(
data=filters,
queryset=user_permitted_objects,
).qs
if object_type == "tags":
editable_ids = set(user_permitted_objects.values_list("pk", flat=True))
all_ids = set(objs.values_list("pk", flat=True))
for tag in objs:
all_ids.update(
descendant.pk
for descendant in tag.get_descendants()
if descendant.pk in editable_ids
)
objs = object_class.objects.filter(pk__in=all_ids)
objs = objs.select_related("owner")
object_ids = list(objs.values_list("pk", flat=True))
else:
objs = object_class.objects.select_related("owner").filter(
pk__in=object_ids,
)
if not user.is_superuser: if not user.is_superuser:
model_name = object_class._meta.model_name perm = f"documents.{perm_codename}"
perm = (
f"documents.change_{model_name}"
if operation == "set_permissions"
else f"documents.delete_{model_name}"
)
has_perms = user.has_perm(perm) and all( has_perms = user.has_perm(perm) and all(
(obj.owner == user or obj.owner is None) for obj in objs has_perms_owner_aware(user, perm_codename, obj) for obj in objs
) )
if not has_perms: if not has_perms:

View File

@@ -9,6 +9,7 @@ from allauth.mfa.recovery_codes.internal.flows import auto_generate_recovery_cod
from allauth.mfa.totp.internal import auth as totp_auth from allauth.mfa.totp.internal import auth as totp_auth
from allauth.socialaccount.adapter import get_adapter from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.models import SocialAccount
from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
@@ -56,17 +57,27 @@ class StandardPagination(PageNumberPagination):
page_size_query_param = "page_size" page_size_query_param = "page_size"
max_page_size = 100000 max_page_size = 100000
def _get_api_version(self) -> int:
request = getattr(self, "request", None)
default_version = settings.REST_FRAMEWORK["DEFAULT_VERSION"]
return int(request.version if request else default_version)
def _should_include_all(self) -> bool:
# TODO: remove legacy `all` support when API v9 is dropped.
return self._get_api_version() < 10
def get_paginated_response(self, data): def get_paginated_response(self, data):
response_data = [
("count", self.page.paginator.count),
("next", self.get_next_link()),
("previous", self.get_previous_link()),
]
if self._should_include_all():
response_data.append(("all", self.get_all_result_ids()))
response_data.append(("results", data))
return Response( return Response(
OrderedDict( OrderedDict(response_data),
[
("count", self.page.paginator.count),
("next", self.get_next_link()),
("previous", self.get_previous_link()),
("all", self.get_all_result_ids()),
("results", data),
],
),
) )
def get_all_result_ids(self): def get_all_result_ids(self):
@@ -87,11 +98,14 @@ class StandardPagination(PageNumberPagination):
def get_paginated_response_schema(self, schema): def get_paginated_response_schema(self, schema):
response_schema = super().get_paginated_response_schema(schema) response_schema = super().get_paginated_response_schema(schema)
response_schema["properties"]["all"] = { if self._should_include_all():
"type": "array", response_schema["properties"]["all"] = {
"example": "[1, 2, 3]", "type": "array",
"items": {"type": "integer"}, "example": "[1, 2, 3]",
} "items": {"type": "integer"},
}
else:
response_schema["properties"].pop("all", None)
return response_schema return response_schema