diff --git a/src/main/webapp/app/admin/dependencies/admin-dependencies.component.html b/src/main/webapp/app/admin/dependencies/admin-dependencies.component.html index 834bf81c66..390661277b 100644 --- a/src/main/webapp/app/admin/dependencies/admin-dependencies.component.html +++ b/src/main/webapp/app/admin/dependencies/admin-dependencies.component.html @@ -107,6 +107,7 @@

Software Dependencies

[totalRecords]="filteredDependencyCount()" [page]="currentPage()" [rows]="pageSize()" + [rowsPerPageOptions]="rowsPerPageOptions" [loading]="isLoading()" [selectable]="false" (lazyLoad)="onPageChange($event)" diff --git a/src/main/webapp/app/admin/dependencies/admin-dependencies.component.ts b/src/main/webapp/app/admin/dependencies/admin-dependencies.component.ts index 2c8d72d472..bbed92903a 100644 --- a/src/main/webapp/app/admin/dependencies/admin-dependencies.component.ts +++ b/src/main/webapp/app/admin/dependencies/admin-dependencies.component.ts @@ -57,6 +57,9 @@ export class AdminDependenciesComponent { /** Number of rows displayed per table page. */ readonly pageSize = signal(25); + /** The dependency list runs to hundreds of entries, so it offers larger pages than the shared default. */ + readonly rowsPerPageOptions: number[] = [25, 50, 100]; + /** Current search query text entered in the search bar. */ readonly searchQuery = signal(''); diff --git a/src/main/webapp/app/application/all-applications/all-applications-page.component.html b/src/main/webapp/app/application/all-applications/all-applications-page.component.html index 7f52ac8f90..8d5459be08 100644 --- a/src/main/webapp/app/application/all-applications/all-applications-page.component.html +++ b/src/main/webapp/app/application/all-applications/all-applications-page.component.html @@ -73,6 +73,7 @@

[totalRecords]="totalRecords()" [page]="page()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [loading]="loading()" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" diff --git a/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.html b/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.html index 13dda039cf..f30bc6880a 100644 --- a/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.html +++ b/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.html @@ -34,6 +34,7 @@

[data]="pageData()" [totalRecords]="total()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [loading]="loading()" (lazyLoad)="loadPage($event)" /> diff --git a/src/main/webapp/app/evaluation/application-overview/application-overview.component.html b/src/main/webapp/app/evaluation/application-overview/application-overview.component.html index 6a12cfe732..8198c7322b 100644 --- a/src/main/webapp/app/evaluation/application-overview/application-overview.component.html +++ b/src/main/webapp/app/evaluation/application-overview/application-overview.component.html @@ -40,6 +40,7 @@

[totalRecords]="total()" [page]="page()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [loading]="loading()" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" diff --git a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html index abc457db4c..7dd4d12fb6 100644 --- a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html +++ b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html @@ -79,6 +79,7 @@ [totalRecords]="totalApplicants()" [page]="pageNumber()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" /> diff --git a/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html b/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html index 23cd5198f1..5c45a8bf22 100644 --- a/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html +++ b/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html @@ -47,6 +47,7 @@

{{ slotDate() }} | {{ slot [rows]="availableInterviewees().length" [totalRecords]="availableInterviewees().length" [hideHeader]="true" + [paginator]="false" /> } diff --git a/src/main/webapp/app/job/all-positions/all-positions-page.component.html b/src/main/webapp/app/job/all-positions/all-positions-page.component.html index bf970f6301..c96c86e6a7 100644 --- a/src/main/webapp/app/job/all-positions/all-positions-page.component.html +++ b/src/main/webapp/app/job/all-positions/all-positions-page.component.html @@ -83,6 +83,7 @@

[totalRecords]="totalRecords()" [page]="page()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [loading]="loading()" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" diff --git a/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.html b/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.html index 1e0d660aa0..e39c58c006 100644 --- a/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.html +++ b/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.html @@ -55,14 +55,18 @@ } @else {

} - @if (canManageSubjectAreaSubscriptions()) {

diff --git a/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.ts b/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.ts index d1d7a42858..8b2ff271b4 100644 --- a/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.ts +++ b/src/main/webapp/app/job/job-overview/job-card-list/job-card-list.component.ts @@ -1,6 +1,5 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { TableLazyLoadEvent, TableModule } from 'primeng/table'; -import { PaginatorModule } from 'primeng/paginator'; +import { TableLazyLoadEvent } from 'primeng/table'; import { firstValueFrom, map } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -14,16 +13,25 @@ import { TranslateDirective } from 'app/shared/language'; import { AccountService } from 'app/core/auth/account.service'; import { JobFormDTOLocationEnum, JobFormDTOSubjectAreaEnum } from 'app/generated/model/job-form-dto'; import { UserShortDTORolesEnum } from 'app/generated/model/user-short-dto'; +import { DynamicTableComponent } from 'app/shared/components/organisms/dynamic-table/dynamic-table.component'; import { ApplicationStatusExtended, JobCardComponent } from '../job-card/job-card.component'; import { JobCardDTO } from '../../../generated/model/job-card-dto'; import { JobResourceApi } from '../../../generated/api/job-resource-api'; import * as DropdownOptions from '../.././dropdown-options'; +export const JOBS_PER_PAGE_STORAGE_KEY = 'jobsPerPage'; + +/** + * The cards wrap into rows of at most six, so the page sizes are multiples of six and leave no + * half-empty row behind. Table page sizes do not have this constraint and keep the shared ones. + */ +export const JOBS_PER_PAGE_OPTIONS: number[] = [6, 12, 18, 24]; + @Component({ selector: 'jhi-job-card-list', standalone: true, - imports: [TableModule, JobCardComponent, PaginatorModule, SearchFilterSortBar, TranslateDirective, RouterLink], + imports: [DynamicTableComponent, JobCardComponent, SearchFilterSortBar, TranslateDirective, RouterLink], templateUrl: './job-card-list.component.html', }) export class JobCardListComponent { @@ -35,6 +43,9 @@ export class JobCardListComponent { pageSize = signal(12); searchQuery = signal(''); + readonly jobsPerPageStorageKey = JOBS_PER_PAGE_STORAGE_KEY; + readonly jobsPerPageOptions = JOBS_PER_PAGE_OPTIONS; + sortBy = signal('startDate'); sortDirection = signal<'ASC' | 'DESC'>('DESC'); @@ -85,6 +96,10 @@ export class JobCardListComponent { void this.loadJobs(); } + onPageSizeHydrated(size: number): void { + this.pageSize.set(size); + } + onSearchEmit(searchQuery: string): void { const normalizedQuery = searchQuery.trim().replace(/\s+/g, ' '); const currentQuery = this.searchQuery().trim().replace(/\s+/g, ' '); diff --git a/src/main/webapp/app/job/my-positions/my-positions-page.component.html b/src/main/webapp/app/job/my-positions/my-positions-page.component.html index e261de8020..5494dbf429 100644 --- a/src/main/webapp/app/job/my-positions/my-positions-page.component.html +++ b/src/main/webapp/app/job/my-positions/my-positions-page.component.html @@ -80,6 +80,7 @@

[totalRecords]="totalRecords()" [page]="page()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [loading]="loading()" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" diff --git a/src/main/webapp/app/service/localStorage.service.ts b/src/main/webapp/app/service/localStorage.service.ts index 2f47826338..bff3259c5a 100644 --- a/src/main/webapp/app/service/localStorage.service.ts +++ b/src/main/webapp/app/service/localStorage.service.ts @@ -68,6 +68,37 @@ export class LocalStorageService { localStorage.setItem(this.SIDEBAR_STATE_KEY, String(this.sidebarCollapsed())); } + // ======================================================= + // PAGE SIZE PREFERENCE + // ======================================================= + + /** + * Returns the user's stored page-size preference for a given key. + * + * @param key storage key identifying the paginated view + * @param fallback value returned when nothing is stored or the value cannot be parsed + * @param allowed optional whitelist; values outside it are treated as missing + * @returns the stored page size if valid, otherwise the fallback + */ + loadPageSize(key: string, fallback: number, allowed?: readonly number[]): number { + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + if (allowed && !allowed.includes(parsed)) return fallback; + return parsed; + } + + /** + * Persists a page-size preference under the given key so it survives navigation and reloads. + * + * @param key storage key identifying the paginated view + * @param pageSize the page size to remember + */ + savePageSize(key: string, pageSize: number): void { + localStorage.setItem(key, String(pageSize)); + } + private getApplicationKey(applicationId?: string, jobId?: string): string { if (hasText(applicationId)) return `application_draft_${applicationId}`; if (hasText(jobId)) return `application_draft_job_${jobId}`; diff --git a/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.html b/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.html index 12260b0f7d..545c739e4a 100644 --- a/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.html +++ b/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.html @@ -5,7 +5,9 @@ [scrollable]="true" [first]="page() * rows()" [rows]="rows()" + [rowsPerPageOptions]="rowsPerPageOptions()" [lazy]="lazy()" + [lazyLoadOnInit]="false" (onLazyLoad)="emitLazy($event)" [tableStyle]="{ width: '100%' }" > diff --git a/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.ts b/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.ts index 76ba2017d2..033a458e58 100644 --- a/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.ts +++ b/src/main/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.ts @@ -1,9 +1,11 @@ -import { Component, TemplateRef, input, output } from '@angular/core'; +import { Component, TemplateRef, afterNextRender, inject, input, output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TableLazyLoadEvent, TableModule } from 'primeng/table'; import { ButtonModule } from 'primeng/button'; import { TranslateDirective } from 'app/shared/language'; import { ProgressSpinnerComponent } from 'app/shared/components/atoms/progress-spinner/progress-spinner.component'; +import { LocalStorageService } from 'app/service/localStorage.service'; +import { BREAKPOINT_QUERIES } from 'app/shared/constants/breakpoints'; export class DynamicTableColumn { field!: string; @@ -14,6 +16,11 @@ export class DynamicTableColumn { template?: TemplateRef; } +export const DEFAULT_ROWS_PER_PAGE_OPTIONS: number[] = [5, 10, 15, 20]; + +/** Stands in for "the user has not picked a page size yet", since any real size is positive. */ +const NO_STORED_SIZE = -1; + @Component({ selector: 'jhi-dynamic-table', standalone: true, @@ -32,10 +39,89 @@ export class DynamicTableComponent { hideHeader = input(false); paginator = input(true); lazy = input(true); + rowsPerPageOptions = input(DEFAULT_ROWS_PER_PAGE_OPTIONS); + storageKey = input(undefined); + /** + * Whether small screens always open on the short page, even when the reader picked a size before. + * Off by default, so a remembered size normally wins. + */ + alwaysUseMobileRows = input(false); lazyLoad = output(); + rowsHydrated = output(); + + private readonly localStorageService = inject(LocalStorageService); + + constructor() { + // The table owns the first load rather than PrimeNG, which would fire it before the page size below + // is known. Changing the size afterwards only relabels the paginator, since PrimeNG does not reload + // when the rows input changes, which would leave a page of the old size on screen under the new label. + afterNextRender(() => { + const initial = this.resolveInitialRows(); + if (initial !== this.rows()) { + this.rowsHydrated.emit(initial); + } + if (this.lazy()) { + this.lazyLoad.emit({ first: 0, rows: initial }); + } + }); + } emitLazy(event: TableLazyLoadEvent): void { + const key = this.storageKey(); + if (key !== undefined && event.rows !== undefined && event.rows !== null && event.rows !== this.rows()) { + this.localStorageService.savePageSize(key, event.rows); + } this.lazyLoad.emit(event); } + + /** + * Works out which page size to start on. + * + * A full page of rows is a long scroll on a phone, so small screens start on a shorter page than + * the view asked for. Views that set {@link alwaysUseMobileRows} keep that short page on every + * visit; everywhere else a size the reader picked before wins. + * + * @returns the page size to start on + */ + private resolveInitialRows(): number { + const mobileRows = this.mobileRows(); + if (mobileRows !== undefined && this.alwaysUseMobileRows()) { + return mobileRows; + } + + const key = this.storageKey(); + if (key !== undefined) { + const stored = this.localStorageService.loadPageSize(key, NO_STORED_SIZE, this.rowsPerPageOptions()); + if (stored !== NO_STORED_SIZE) { + return stored; + } + } + return mobileRows ?? this.rows(); + } + + /** + * The shortest page the view offers, which is where small screens start: a full page of rows is a + * long scroll on a phone. Taking it from the offered sizes keeps it valid for views that set their + * own, such as the job cards, whose sizes are multiples of a full row. + * + * @returns the shortest page size on a phone-sized viewport, otherwise {@code undefined} + */ + private mobileRows(): number | undefined { + const options = this.rowsPerPageOptions(); + if (!this.isMobileViewport() || options.length === 0) { + return undefined; + } + return Math.min(...options); + } + + /** + * @returns {@code true} if the viewport is phone-sized, {@code false} where it cannot be determined + */ + private isMobileViewport(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(BREAKPOINT_QUERIES.onlyMobile).matches; + } } diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-admin-view/research-group-admin-view.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-admin-view/research-group-admin-view.component.html index 5a854b3db2..fe04424b82 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-admin-view/research-group-admin-view.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-admin-view/research-group-admin-view.component.html @@ -66,6 +66,7 @@

[totalRecords]="totalRecords()" [page]="page()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" (lazyLoad)="loadOnTableEmit($event)" /> diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-departments/research-group-departments.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-departments/research-group-departments.component.html index 9c0bd0247b..9a287d4fb9 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-departments/research-group-departments.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-departments/research-group-departments.component.html @@ -32,6 +32,7 @@

Departments

[totalRecords]="total()" [page]="pageNumber()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" /> diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-members/research-group-members.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-members/research-group-members.component.html index eb8b452779..894e8828f0 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-members/research-group-members.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-members/research-group-members.component.html @@ -38,6 +38,7 @@

[totalRecords]="total()" [page]="pageNumber()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" /> diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-schools/research-group-schools.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-schools/research-group-schools.component.html index c04baa69a7..377f7e0d5c 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-schools/research-group-schools.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-schools/research-group-schools.component.html @@ -30,6 +30,7 @@

Schools

[totalRecords]="total()" [page]="pageNumber()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [selectable]="false" (lazyLoad)="loadOnTableEmit($event)" /> diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-templates/research-group-templates.html b/src/main/webapp/app/usermanagement/research-group/research-group-templates/research-group-templates.html index 3180c2a633..6be90db3b0 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-templates/research-group-templates.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-templates/research-group-templates.html @@ -15,6 +15,7 @@

[columns]="columns()" [data]="tableData()" [rows]="pageSize()" + (rowsHydrated)="pageSize.set($event)" [totalRecords]="total()" [page]="pageNumber()" (lazyLoad)="onTableEmit($event)" diff --git a/src/test/webapp/app/job/job-overview/job-card-list/job-card-list.component.spec.ts b/src/test/webapp/app/job/job-overview/job-card-list/job-card-list.component.spec.ts index c42fa449b1..344beaaeeb 100644 --- a/src/test/webapp/app/job/job-overview/job-card-list/job-card-list.component.spec.ts +++ b/src/test/webapp/app/job/job-overview/job-card-list/job-card-list.component.spec.ts @@ -30,6 +30,7 @@ describe('JobCardListComponent', () => { let mockToastService = createToastServiceMock(); beforeEach(async () => { + localStorage.clear(); jobApi = { getAllFilters: vi.fn().mockReturnValue( of({ @@ -185,6 +186,12 @@ describe('JobCardListComponent', () => { expect(spy).toHaveBeenCalledOnce(); }); + it('should update pageSize when dynamic-table reports a hydrated value', () => { + component.onPageSizeHydrated(30); + + expect(component.pageSize()).toBe(30); + }); + it('should set empty jobs and totalRecords when API returns no content', async () => { jobApi.getAvailableJobs.mockReturnValueOnce(of({ content: undefined, totalElements: undefined })); diff --git a/src/test/webapp/app/service/localStorage.service.spec.ts b/src/test/webapp/app/service/localStorage.service.spec.ts index 9fef0d14a0..375fc69496 100644 --- a/src/test/webapp/app/service/localStorage.service.spec.ts +++ b/src/test/webapp/app/service/localStorage.service.spec.ts @@ -90,6 +90,25 @@ describe('LocalStorageService', () => { expect(() => getApplicationKey.call(service, undefined, undefined)).toThrowError(); }); + it('should return the fallback when no page-size preference is stored', () => { + expect(service.loadPageSize('jobsPerPage', 10)).toBe(10); + }); + + it('should return the stored page-size preference', () => { + service.savePageSize('jobsPerPage', 20); + expect(service.loadPageSize('jobsPerPage', 10)).toBe(20); + }); + + it('should fall back when the stored page-size value is not in the allowed set', () => { + service.savePageSize('jobsPerPage', 7); + expect(service.loadPageSize('jobsPerPage', 10, [5, 10, 15, 20])).toBe(10); + }); + + it('should fall back when the stored page-size value cannot be parsed', () => { + localStorage.setItem('jobsPerPage', 'not-a-number'); + expect(service.loadPageSize('jobsPerPage', 10)).toBe(10); + }); + it('rethrows error when JSON.stringify fails (circular data)', () => { const circularPersonal: ApplicationDraftData['personalInfoData'] & { self?: any } = { firstName: emptyPersonalInfo.firstName, diff --git a/src/test/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.spec.ts b/src/test/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.spec.ts new file mode 100644 index 0000000000..988eebe7ad --- /dev/null +++ b/src/test/webapp/app/shared/components/organisms/dynamic-table/dynamic-table.component.spec.ts @@ -0,0 +1,269 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { DynamicTableComponent } from 'app/shared/components/organisms/dynamic-table/dynamic-table.component'; +import { LocalStorageService } from 'app/service/localStorage.service'; +import { provideTranslateMock } from 'src/test/webapp/util/translate.mock'; +import { provideFontAwesomeTesting } from 'src/test/webapp/util/fontawesome.testing'; + +describe('DynamicTableComponent', () => { + let fixture: ComponentFixture; + let component: DynamicTableComponent; + + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [DynamicTableComponent], + providers: [provideTranslateMock(), provideFontAwesomeTesting()], + }).compileComponents(); + + fixture = TestBed.createComponent(DynamicTableComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not emit rowsHydrated when no storageKey is set', () => { + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should hydrate rows from localStorage on init when storageKey is set', () => { + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + localStorage.setItem('jobsPerPage', '20'); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith(20); + }); + + it('should not emit rowsHydrated when the stored value matches the current rows input', () => { + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('rows', 20); + localStorage.setItem('jobsPerPage', '20'); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should ignore stored values outside the allowed options', () => { + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + localStorage.setItem('jobsPerPage', '7'); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + + describe('mobile default', () => { + const setViewport = (mobile: boolean): void => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ matches: mobile, media: query })), + ); + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should start on a shorter page on a phone-sized viewport', () => { + setViewport(true); + fixture.componentRef.setInput('rows', 10); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith(5); + }); + + it('should keep the size the view asked for on a larger viewport', () => { + setViewport(false); + fixture.componentRef.setInput('rows', 10); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should let a stored preference win over the mobile default', () => { + setViewport(true); + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('rows', 10); + localStorage.setItem('jobsPerPage', '20'); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith(20); + }); + + it('should not emit when the shortest size on offer is already what the view asked for', () => { + setViewport(true); + fixture.componentRef.setInput('rows', 25); + fixture.componentRef.setInput('rowsPerPageOptions', [25, 50, 100]); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should keep the short page over a stored size when the view always uses it', () => { + setViewport(true); + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('alwaysUseMobileRows', true); + fixture.componentRef.setInput('rows', 10); + localStorage.setItem('jobsPerPage', '20'); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith(5); + }); + + it('should still honour a stored size on a larger viewport when the view always uses the short page', () => { + setViewport(false); + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('alwaysUseMobileRows', true); + fixture.componentRef.setInput('rows', 10); + localStorage.setItem('jobsPerPage', '20'); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith(20); + }); + + it('should take the shortest size a view offers rather than a fixed one', () => { + setViewport(true); + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('alwaysUseMobileRows', true); + fixture.componentRef.setInput('rowsPerPageOptions', [6, 12, 18, 24]); + fixture.componentRef.setInput('rows', 12); + localStorage.setItem('jobsPerPage', '24'); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith(6); + }); + + it('should not emit when the view already asks for the mobile default', () => { + setViewport(true); + fixture.componentRef.setInput('rows', 5); + const spy = vi.fn(); + component.rowsHydrated.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + it('should persist a new rows value on lazy load when storageKey is set', () => { + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('rows', 10); + fixture.detectChanges(); + const saveSpy = vi.spyOn(TestBed.inject(LocalStorageService), 'savePageSize'); + + component.emitLazy({ first: 0, rows: 20 }); + + expect(saveSpy).toHaveBeenCalledExactlyOnceWith('jobsPerPage', 20); + }); + + it('should not write to localStorage when lazy-load reports the same rows', () => { + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('rows', 10); + fixture.detectChanges(); + const saveSpy = vi.spyOn(TestBed.inject(LocalStorageService), 'savePageSize'); + + component.emitLazy({ first: 0, rows: 10 }); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('should not write to localStorage when storageKey is not set', () => { + fixture.componentRef.setInput('rows', 10); + fixture.detectChanges(); + const saveSpy = vi.spyOn(TestBed.inject(LocalStorageService), 'savePageSize'); + + component.emitLazy({ first: 0, rows: 20 }); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + + describe('first load', () => { + it('should request the first page itself using the resolved page size', () => { + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('rows', 10); + localStorage.setItem('jobsPerPage', '20'); + const spy = vi.fn(); + component.lazyLoad.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith({ first: 0, rows: 20 }); + }); + + it('should request the first page using the size the view asked for when nothing overrides it', () => { + fixture.componentRef.setInput('rows', 10); + const spy = vi.fn(); + component.lazyLoad.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledExactlyOnceWith({ first: 0, rows: 10 }); + }); + + it('should leave the first load alone when the table is not lazy', () => { + fixture.componentRef.setInput('lazy', false); + const spy = vi.fn(); + component.lazyLoad.subscribe(spy); + + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not store the resolved size, which the reader did not choose', () => { + fixture.componentRef.setInput('storageKey', 'jobsPerPage'); + fixture.componentRef.setInput('rows', 10); + const saveSpy = vi.spyOn(TestBed.inject(LocalStorageService), 'savePageSize'); + + fixture.detectChanges(); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + }); + + it('should forward lazyLoad events to consumers', () => { + const spy = vi.fn(); + component.lazyLoad.subscribe(spy); + + component.emitLazy({ first: 20, rows: 10 }); + + expect(spy).toHaveBeenCalledExactlyOnceWith({ first: 20, rows: 10 }); + }); +});