Feat: Replace Load More with Previous/Next pagination in library

Page size changes from 50 to 24. Library now shows discrete page navigation
with a "Page N of M" indicator, total image count, and URL state (?page=N)
so pages are bookmarkable and the browser Back button works. Tag filter
resets to page 1. Out-of-range page params are clamped silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 21:08:42 +00:00
parent e5e1acb533
commit 781be909bc
14 changed files with 677 additions and 22 deletions

View File

@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, ActivatedRoute } from '@angular/router';
import { provideRouter, ActivatedRoute, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { LibraryComponent } from './library.component';
import { ImageService } from '../services/image.service';
import { routes } from '../app.routes';
@@ -17,10 +17,19 @@ function makeActivatedRoute(queryParams: Record<string, string> = {}) {
};
}
const EMPTY_PAGE = { items: [], total: 0, limit: 50, offset: 0 };
const EMPTY_PAGE = { items: [], total: 0, limit: 24, offset: 0 };
const ONE_IMAGE = {
items: [{ id: '1', filename: 'a.jpg', tags: ['cat'], hash: '', mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1, storage_key: '', thumbnail_key: null, file_url: '/api/v1/images/1/file', thumbnail_url: null, created_at: '' }],
total: 1, limit: 50, offset: 0,
total: 1, limit: 24, offset: 0,
};
const MULTI_PAGE = {
items: Array(24).fill(null).map((_, i) => ({
id: String(i + 1), filename: `img${i + 1}.jpg`, tags: [], hash: '',
mime_type: 'image/jpeg', size_bytes: 1, width: 1, height: 1,
storage_key: '', thumbnail_key: null,
file_url: `/api/v1/images/${i + 1}/file`, thumbnail_url: null, created_at: '',
})),
total: 48, limit: 24, offset: 0,
};
describe('LibraryComponent', () => {
@@ -74,14 +83,16 @@ describe('LibraryComponent', () => {
it('shows error card when error is true', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.error = true;
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(throwError(() => new Error('fail')));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.error-card')).not.toBeNull();
});
it('error card has retry button that calls load()', () => {
const fixture = TestBed.createComponent(LibraryComponent);
fixture.componentInstance.error = true;
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(throwError(() => new Error('fail')));
fixture.detectChanges();
spyOn(fixture.componentInstance, 'load');
const retryBtn = (fixture.nativeElement as HTMLElement).querySelector('.error-card .retry-btn') as HTMLButtonElement;
@@ -145,4 +156,142 @@ describe('LibraryComponent', () => {
const link = (fixture.nativeElement as HTMLElement).querySelector('a[href="/tags"]');
expect(link).not.toBeNull();
});
// ---- Pagination: US1 ----
it('page indicator shows "Page 1 of 2" when totalPages > 1', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const indicator = (fixture.nativeElement as HTMLElement).querySelector('.page-indicator');
expect(indicator?.textContent).toContain('Page 1 of 2');
});
it('total count renders with correct number', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const el = (fixture.nativeElement as HTMLElement).querySelector('.total-count');
expect(el?.textContent).toContain('48');
});
it('"Next" button present when not on last page', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).not.toBeNull();
});
it('"Previous" button absent on first page', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).toBeNull();
});
it('"Previous" present and "Next" absent on last page', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).not.toBeNull();
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).toBeNull();
});
it('no pagination controls when all images fit on one page', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(ONE_IMAGE));
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.pagination-bar')).toBeNull();
});
it('nextPage() calls imageService.list with offset=24', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
listSpy.calls.reset();
fixture.componentInstance.nextPage();
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
});
it('prevPage() from page 2 calls imageService.list with offset=0', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
fixture.componentInstance.currentPage = 2;
fixture.componentInstance.totalPages = 2;
listSpy.calls.reset();
fixture.componentInstance.prevPage();
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0);
});
it('applyFilter() resets to page 1 (offset=0)', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
fixture.componentInstance.currentPage = 2;
listSpy.calls.reset();
fixture.componentInstance.applyFilter(['cat']);
expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0);
});
// ---- Pagination: US2 — URL state ----
it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
expect(fixture.componentInstance.currentPage).toBe(2);
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
});
it('clamps out-of-range ?page=9999 to page 1 after load resolves', () => {
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '9999' }) });
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
// After load, totalPages=2, currentPage should be clamped to 2 (not 9999), then router corrects URL
expect(fixture.componentInstance.currentPage).toBeLessThanOrEqual(fixture.componentInstance.totalPages);
});
it('nextPage() calls router.navigate with page=2 and queryParamsHandling merge', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
fixture.componentInstance.nextPage();
expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({
queryParams: jasmine.objectContaining({ page: 2 }),
queryParamsHandling: 'merge',
}));
});
it('applyFilter() calls router.navigate with page=1 and queryParamsHandling merge', () => {
const fixture = TestBed.createComponent(LibraryComponent);
const imgSvc = TestBed.inject(ImageService);
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
fixture.detectChanges();
fixture.componentInstance.currentPage = 2;
const router = TestBed.inject(Router);
spyOn(router, 'navigate');
fixture.componentInstance.applyFilter(['dog']);
expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({
queryParams: jasmine.objectContaining({ page: 1 }),
queryParamsHandling: 'merge',
}));
});
});