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 });
+ });
+});