mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-03 23:22:42 -06:00
Enhancement: per-type object page sizing (#11977)
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||
}
|
||||
@if (info) {
|
||||
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
<ng-template #infoPopover>
|
||||
@@ -26,6 +26,9 @@
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
@if (loading) {
|
||||
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="btn-toolbar col col-md-auto gap-2">
|
||||
|
||||
@@ -42,6 +42,9 @@ export class PageHeaderComponent {
|
||||
@Input()
|
||||
infoLink: string
|
||||
|
||||
@Input()
|
||||
loading: boolean = false
|
||||
|
||||
public copyID() {
|
||||
this.copied = this.clipboard.copy(this.id.toString())
|
||||
clearTimeout(this.copyTimeout)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
|
||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
|
||||
|
||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||
@@ -46,14 +46,30 @@
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md mb-2 mb-xl-0">
|
||||
<div class="col mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
<div class="col-auto mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||
<span class="input-group-text border-0" i18n>Show:</span>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-auto me-3">
|
||||
<select class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
||||
</div>
|
||||
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card border table-responsive mb-3">
|
||||
@@ -76,7 +92,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
@if (loading && data.length === 0) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
@@ -91,7 +107,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!loading) {
|
||||
@if (!loading || data.length > 0) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
@@ -102,7 +118,7 @@
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -24,3 +24,7 @@ td.name-cell {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
select.small {
|
||||
font-size: 0.875rem !important; // 14px
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
|
||||
@@ -79,6 +81,7 @@ describe('ManagementListComponent', () => {
|
||||
let toastService: ToastService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let permissionsService: PermissionsService
|
||||
let settingsService: SettingsService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -130,6 +133,7 @@ describe('ManagementListComponent', () => {
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
fixture = TestBed.createComponent(TagListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
@@ -447,4 +451,66 @@ describe('ManagementListComponent', () => {
|
||||
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
|
||||
expect(ids).toEqual([1, 5])
|
||||
})
|
||||
|
||||
it('pageSize getter should return stored page size or default to 25', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 50 })
|
||||
component.typeNamePlural = 'tags'
|
||||
|
||||
expect(component.pageSize).toBe(50)
|
||||
})
|
||||
|
||||
it('pageSize getter should return 25 when no size is stored', () => {
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({})
|
||||
component.typeNamePlural = 'tags'
|
||||
|
||||
expect(component.pageSize).toBe(25)
|
||||
})
|
||||
|
||||
it('pageSize setter should update settings, reset page and reload data on success', fakeAsync(() => {
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
|
||||
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(of({ success: true }))
|
||||
|
||||
component.typeNamePlural = 'tags'
|
||||
component.page = 2
|
||||
component.pageSize = 100
|
||||
|
||||
tick()
|
||||
|
||||
expect(settingsService.set).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.OBJECT_LIST_SIZES,
|
||||
{ tags: 100 }
|
||||
)
|
||||
expect(component.page).toBe(1)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('pageSize setter should show error toast on settings store failure', fakeAsync(() => {
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
|
||||
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(throwError(() => new Error('error storing settings')))
|
||||
|
||||
component.typeNamePlural = 'tags'
|
||||
component.pageSize = 50
|
||||
|
||||
tick()
|
||||
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving settings',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MatchingModel,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
SortableDirective,
|
||||
SortEvent,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
AbstractNameFilterService,
|
||||
BulkEditObjectOperation,
|
||||
} from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
@@ -80,6 +82,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
public permissionType: PermissionType
|
||||
public extraColumns: ManagementListColumn[]
|
||||
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
|
||||
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
||||
|
||||
public data: T[] = []
|
||||
@@ -160,7 +164,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
this.service
|
||||
.listFiltered(
|
||||
this.page,
|
||||
null,
|
||||
this.pageSize,
|
||||
this.sortField,
|
||||
this.sortReverse,
|
||||
this._nameFilter,
|
||||
@@ -280,6 +284,30 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
if (event.code == 'Escape') this.nameFilterDebounce.next(null)
|
||||
}
|
||||
|
||||
public get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)[
|
||||
this.typeNamePlural
|
||||
] || 25
|
||||
)
|
||||
}
|
||||
|
||||
public set pageSize(newPageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
[this.typeNamePlural]: newPageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page = 1
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
userCanDelete(object: ObjectWithPermissions): boolean {
|
||||
return this.permissionsService.currentUserOwnsObject(object)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('TagListComponent', () => {
|
||||
it('should request only parent tags when no name filter is applied', () => {
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
25,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -116,7 +116,7 @@ describe('TagListComponent', () => {
|
||||
component.reloadData()
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
25,
|
||||
undefined,
|
||||
undefined,
|
||||
'Tag',
|
||||
|
||||
@@ -63,6 +63,7 @@ export const SETTINGS_KEYS = {
|
||||
SIDEBAR_VIEWS_SHOW_COUNT:
|
||||
'general-settings:saved-views:sidebar-views-show-count',
|
||||
TOUR_COMPLETE: 'general-settings:tour-complete',
|
||||
OBJECT_LIST_SIZES: 'general-settings:object-list-sizes',
|
||||
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
|
||||
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
|
||||
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
|
||||
@@ -201,6 +202,16 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.OBJECT_LIST_SIZES,
|
||||
type: 'object',
|
||||
default: {
|
||||
correspondents: 25,
|
||||
document_types: 25,
|
||||
tags: 25,
|
||||
storage_paths: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
|
||||
type: 'number',
|
||||
|
||||
Reference in New Issue
Block a user