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:
@@ -5,6 +5,7 @@ module.exports = function (config) {
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-firefox-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma'),
|
||||
|
||||
57
ui/package-lock.json
generated
57
ui/package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-firefox-launcher": "^2.1.3",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"prettier": "^3.2.0",
|
||||
@@ -10829,6 +10830,62 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/karma-firefox-launcher": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.3.tgz",
|
||||
"integrity": "sha512-LMM2bseebLbYjODBOVt7TCPP9OI2vZIXCavIXhkO9m+10Uj5l7u/SKoeRmYx8FYHTVGZSpk6peX+3BMHC1WwNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^2.2.0",
|
||||
"which": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/karma-firefox-launcher/node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/karma-firefox-launcher/node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/karma-firefox-launcher/node_modules/which": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz",
|
||||
"integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/which.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/karma-jasmine": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-firefox-launcher": "^2.1.3",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"prettier": "^3.2.0",
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,15 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button *ngIf="hasMore && !showSpinner && !error" class="load-more" (click)="loadMore()">Load more</button>
|
||||
<!-- Total count — always visible when images exist -->
|
||||
<p *ngIf="total > 0 && !showSpinner && !error" class="total-count">{{ total }} images</p>
|
||||
|
||||
<!-- Pagination controls — only when more than one page -->
|
||||
<div *ngIf="totalPages > 1 && !showSpinner && !error" class="pagination-bar">
|
||||
<button *ngIf="currentPage > 1" class="prev-btn" (click)="prevPage()">← Previous</button>
|
||||
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
|
||||
<button *ngIf="currentPage < totalPages" class="next-btn" (click)="nextPage()">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -119,7 +127,11 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
.error-card p { color: var(--text-muted); margin-bottom: 16px; }
|
||||
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
||||
.retry-btn:hover { border-color: var(--border-focus); }
|
||||
.load-more { display: block; margin: 24px auto; padding: 10px 32px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
|
||||
.total-count { text-align: center; color: var(--text-muted); font-size: 0.85rem; margin: 16px 0 8px; }
|
||||
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 16px; margin: 16px 0 24px; }
|
||||
.prev-btn, .next-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
||||
.prev-btn:hover, .next-btn:hover { border-color: var(--border-focus); }
|
||||
.page-indicator { color: var(--text-muted); font-size: 0.9rem; }
|
||||
`],
|
||||
})
|
||||
export class LibraryComponent implements OnInit {
|
||||
@@ -129,10 +141,11 @@ export class LibraryComponent implements OnInit {
|
||||
suggestions: { name: string; image_count: number }[] = [];
|
||||
showSpinner = false;
|
||||
error = false;
|
||||
hasMore = false;
|
||||
currentPage = 1;
|
||||
totalPages = 1;
|
||||
total = 0;
|
||||
readonly skeletonItems = Array(8).fill(null);
|
||||
private offset = 0;
|
||||
private readonly limit = 50;
|
||||
private readonly limit = 24;
|
||||
private readonly filterChange$ = new Subject<string>();
|
||||
|
||||
constructor(
|
||||
@@ -148,6 +161,10 @@ export class LibraryComponent implements OnInit {
|
||||
if (tagsParam) {
|
||||
this.activeFilters = tagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
|
||||
}
|
||||
const pageParam = this.route.snapshot.queryParamMap.get('page');
|
||||
if (pageParam) {
|
||||
this.currentPage = Math.max(1, parseInt(pageParam, 10) || 1);
|
||||
}
|
||||
this.load();
|
||||
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
|
||||
if (q) {
|
||||
@@ -164,16 +181,22 @@ export class LibraryComponent implements OnInit {
|
||||
|
||||
load(): void {
|
||||
this.error = false;
|
||||
const req$ = this.imageService.list(this.activeFilters, this.limit, this.offset).pipe(share());
|
||||
const offset = (this.currentPage - 1) * this.limit;
|
||||
const req$ = this.imageService.list(this.activeFilters, this.limit, offset).pipe(share());
|
||||
timer(150).pipe(takeUntil(req$)).subscribe(() => {
|
||||
this.showSpinner = true;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
req$.subscribe({
|
||||
next: (res) => {
|
||||
this.images = [...this.images, ...res.items];
|
||||
this.offset += res.items.length;
|
||||
this.hasMore = this.offset < res.total;
|
||||
this.images = res.items;
|
||||
this.total = res.total;
|
||||
this.totalPages = Math.ceil(res.total / this.limit) || 1;
|
||||
const clamped = Math.max(1, Math.min(this.currentPage, this.totalPages));
|
||||
if (clamped !== this.currentPage) {
|
||||
this.currentPage = clamped;
|
||||
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
|
||||
}
|
||||
this.showSpinner = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
@@ -185,6 +208,22 @@ export class LibraryComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
prevPage(): void {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
onTagInput(event: Event): void {
|
||||
const val = (event.target as HTMLInputElement).value;
|
||||
this.tagSearch = val;
|
||||
@@ -207,12 +246,12 @@ export class LibraryComponent implements OnInit {
|
||||
|
||||
applyFilter(tags: string[]): void {
|
||||
this.activeFilters = tags;
|
||||
this.offset = 0;
|
||||
this.currentPage = 1;
|
||||
this.images = [];
|
||||
this.load();
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
this.router.navigate([], {
|
||||
queryParams: { page: 1, tags: tags.length ? tags.join(',') : null },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user