Enhancement: per-type object page sizing (#11977)

This commit is contained in:
shamoon
2026-02-02 11:03:54 -08:00
committed by GitHub
parent 470c824684
commit e5edfd0f7f
8 changed files with 141 additions and 10 deletions

View File

@@ -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">

View File

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

View File

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

View File

@@ -24,3 +24,7 @@ td.name-cell {
margin-left: .5rem;
}
}
select.small {
font-size: 0.875rem !important; // 14px
}

View File

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

View File

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

View File

@@ -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',

View File

@@ -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',