Compare commits
5 Commits
017-short-
...
28113f38e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 28113f38e6 | |||
| d883b76c0d | |||
| 0ad82e60ac | |||
| 40ceecda76 | |||
| fca3190eb1 |
@@ -1 +1 @@
|
||||
{"feature_directory":"specs/017-short-id-migration"}
|
||||
{"feature_directory":"specs/018-pagination-controls"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan at
|
||||
`specs/017-short-id-migration/plan.md`.
|
||||
`specs/018-pagination-controls/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
@@ -34,6 +34,7 @@ RUN groupadd --system --gid 1001 appgroup \
|
||||
&& useradd --system --uid 1001 --gid 1001 --no-create-home appuser
|
||||
|
||||
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
||||
# Explicitly list every source directory — add new top-level dirs here or they won't exist in prod
|
||||
COPY --chown=appuser:appgroup app/ ./app/
|
||||
COPY --chown=appuser:appgroup alembic/ ./alembic/
|
||||
COPY --chown=appuser:appgroup alembic.ini .
|
||||
|
||||
0
specs/001-reaction-image-board/SHIPPED
Normal file
0
specs/001-reaction-image-board/SHIPPED
Normal file
0
specs/002-api-image-proxy/SHIPPED
Normal file
0
specs/002-api-image-proxy/SHIPPED
Normal file
0
specs/003-upload-thumbnails/SHIPPED
Normal file
0
specs/003-upload-thumbnails/SHIPPED
Normal file
0
specs/004-jwt-bearer-auth/SHIPPED
Normal file
0
specs/004-jwt-bearer-auth/SHIPPED
Normal file
0
specs/005-ui-polish/SHIPPED
Normal file
0
specs/005-ui-polish/SHIPPED
Normal file
0
specs/006-header-nav-signout/SHIPPED
Normal file
0
specs/006-header-nav-signout/SHIPPED
Normal file
0
specs/007-tag-browser/SHIPPED
Normal file
0
specs/007-tag-browser/SHIPPED
Normal file
0
specs/008-postgres-integration-tests/SHIPPED
Normal file
0
specs/008-postgres-integration-tests/SHIPPED
Normal file
0
specs/009-login-rate-limiting/SHIPPED
Normal file
0
specs/009-login-rate-limiting/SHIPPED
Normal file
0
specs/010-api-prod-dockerfile/SHIPPED
Normal file
0
specs/010-api-prod-dockerfile/SHIPPED
Normal file
0
specs/011-ui-prod-dockerfile/SHIPPED
Normal file
0
specs/011-ui-prod-dockerfile/SHIPPED
Normal file
0
specs/012-api-docs-gate/SHIPPED
Normal file
0
specs/012-api-docs-gate/SHIPPED
Normal file
0
specs/013-k8s-manifests/SHIPPED
Normal file
0
specs/013-k8s-manifests/SHIPPED
Normal file
0
specs/014-r2-cdn-serving/SHIPPED
Normal file
0
specs/014-r2-cdn-serving/SHIPPED
Normal file
0
specs/015-library-pagination/SHIPPED
Normal file
0
specs/015-library-pagination/SHIPPED
Normal file
0
specs/016-copy-url-toast/SHIPPED
Normal file
0
specs/016-copy-url-toast/SHIPPED
Normal file
0
specs/017-short-id-migration/SHIPPED
Normal file
0
specs/017-short-id-migration/SHIPPED
Normal file
0
specs/018-pagination-controls/SHIPPED
Normal file
0
specs/018-pagination-controls/SHIPPED
Normal file
30
specs/018-pagination-controls/checklists/requirements.md
Normal file
30
specs/018-pagination-controls/checklists/requirements.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Specification Quality Checklist: Pagination Controls Redesign
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-10
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [X] No implementation details (languages, frameworks, APIs)
|
||||
- [X] Focused on user value and business needs
|
||||
- [X] Written for non-technical stakeholders
|
||||
- [X] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||
- [X] Requirements are testable and unambiguous
|
||||
- [X] Success criteria are measurable
|
||||
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||
- [X] All acceptance scenarios are defined
|
||||
- [X] Edge cases are identified
|
||||
- [X] Scope is clearly bounded
|
||||
- [X] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [X] All functional requirements have clear acceptance criteria
|
||||
- [X] User scenarios cover primary flows
|
||||
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [X] No implementation details leak into specification
|
||||
102
specs/018-pagination-controls/plan.md
Normal file
102
specs/018-pagination-controls/plan.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Implementation Plan: Pagination Controls Redesign
|
||||
|
||||
**Branch**: `018-pagination-controls` | **Date**: 2026-05-10 | **Spec**: [spec.md](spec.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the existing "← Previous / Page X of Y / Next →" pagination bar in `LibraryComponent` with six controls: first-page («), previous-page (‹), up to four numbered page buttons, next-page (›), and last-page (»). All logic stays in the existing component — no new component is introduced (§2.6: no speculative abstraction, only one paginated view exists).
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript (strict mode)
|
||||
**Primary Dependencies**: Angular (latest stable), Karma + Jasmine
|
||||
**Storage**: N/A — no data layer changes
|
||||
**Testing**: Angular TestBed unit tests (component spec)
|
||||
**Target Platform**: Browser SPA
|
||||
**Project Type**: Web application — UI only
|
||||
**Performance Goals**: No measurable regression in render or navigation time
|
||||
**Constraints**: ESLint + Prettier must pass (§7.3); all existing tests must continue to pass (§5.4)
|
||||
**Scale/Scope**: Single component change; one paginated view in the app
|
||||
|
||||
## Constitution Check
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| §2.6 No speculative abstraction | ✅ PASS | Pagination logic stays inline in LibraryComponent; no new component introduced |
|
||||
| §5.1 Tests alongside implementation | ✅ PASS | Spec tests for window algorithm, disabled states, and navigation covered in tasks |
|
||||
| §5.2 Test pyramid | ✅ PASS | Unit tests via TestBed; no integration or E2E tests required for a template change |
|
||||
| §5.4 Suite must pass before done | ✅ PASS | Gate enforced per task |
|
||||
| §7.3 Lint/format enforced | ✅ PASS | ESLint + Prettier gate on all tasks |
|
||||
| §8 Scope boundaries | ✅ PASS | No out-of-scope work touched |
|
||||
|
||||
No violations. No Complexity Tracking table needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/018-pagination-controls/
|
||||
├── plan.md ← this file
|
||||
├── research.md
|
||||
└── tasks.md (generated by /speckit-tasks)
|
||||
```
|
||||
|
||||
### Source Code (changed files only)
|
||||
|
||||
```text
|
||||
ui/src/app/library/
|
||||
├── library.component.ts ← template, styles, class (page window getter + goToPage/firstPage/lastPage methods)
|
||||
└── library.component.spec.ts ← new tests for window algorithm, disabled states, button navigation
|
||||
```
|
||||
|
||||
No new files. No API changes. No data model changes.
|
||||
|
||||
## Page Window Algorithm
|
||||
|
||||
Given `currentPage` (1-based) and `totalPages`, compute the array of up to four page numbers to display:
|
||||
|
||||
```
|
||||
start = max(1, currentPage - 1)
|
||||
end = min(totalPages, start + 3)
|
||||
start = max(1, end - 3) ← re-anchor if near the end
|
||||
pages = [start .. end]
|
||||
```
|
||||
|
||||
Examples:
|
||||
- Page 1 of 20 → [1, 2, 3, 4]
|
||||
- Page 7 of 20 → [6, 7, 8, 9]
|
||||
- Page 19 of 20 → [17, 18, 19, 20]
|
||||
- Page 2 of 3 → [1, 2, 3]
|
||||
|
||||
## New Controls Layout
|
||||
|
||||
```
|
||||
« ‹ [1] [2] [3] [4] › »
|
||||
```
|
||||
|
||||
- `«` disabled when `currentPage === 1`
|
||||
- `‹` disabled when `currentPage === 1`
|
||||
- Active page button has distinct active style
|
||||
- `›` disabled when `currentPage === totalPages`
|
||||
- `»` disabled when `currentPage === totalPages`
|
||||
- Entire bar hidden when `totalPages <= 1` (existing behaviour retained)
|
||||
|
||||
## Methods to Add/Change
|
||||
|
||||
| Method | Change |
|
||||
|--------|--------|
|
||||
| `get pageWindow(): number[]` | New getter — returns array of up to 4 page numbers |
|
||||
| `goToPage(page: number)` | New — navigates to arbitrary page number |
|
||||
| `firstPage()` | New — navigates to page 1 |
|
||||
| `lastPage()` | New — navigates to last page |
|
||||
| `nextPage()` | Existing — no change needed |
|
||||
| `prevPage()` | Existing — no change needed |
|
||||
|
||||
## Research
|
||||
|
||||
No unknowns. Tech stack is fixed (Angular/TypeScript). The windowing algorithm is a standard sliding-window with boundary clamping. No external research required.
|
||||
|
||||
**Decision**: Keep all logic in `LibraryComponent` (no child component).
|
||||
**Rationale**: §2.6 prohibits speculative abstraction; only one paginated view exists in the app. Extracting a `PaginationComponent` would be justified only when a second use case appears.
|
||||
**Alternatives considered**: Standalone `PaginationComponent` — rejected; no second consumer.
|
||||
92
specs/018-pagination-controls/spec.md
Normal file
92
specs/018-pagination-controls/spec.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Feature Specification: Pagination Controls Redesign
|
||||
|
||||
**Feature Branch**: `018-pagination-controls`
|
||||
**Created**: 2026-05-10
|
||||
**Status**: Draft
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Navigate by Page Number (Priority: P1)
|
||||
|
||||
A user browsing the image library wants to jump directly to a specific page by clicking a numbered button rather than stepping through pages one at a time.
|
||||
|
||||
**Why this priority**: Direct page navigation is the core value of this feature — without numbered buttons the redesign delivers nothing new.
|
||||
|
||||
**Independent Test**: Load the library with enough images to produce multiple pages, confirm four numbered page buttons are visible, click one, and verify the correct page of images loads.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the library has more than one page of images, **When** the user views the pagination bar, **Then** up to four page number buttons are visible.
|
||||
2. **Given** four page buttons are shown, **When** the user clicks a page number button, **Then** the library displays the images for that page and the clicked button appears in an active/selected state.
|
||||
3. **Given** the total number of pages is four or fewer, **When** the user views the pagination bar, **Then** all pages are shown as numbered buttons with none hidden.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Step Forward and Backward (Priority: P2)
|
||||
|
||||
A user wants to move one page at a time using previous and next controls without having to locate a specific page number.
|
||||
|
||||
**Why this priority**: Sequential navigation is a common browsing pattern and complements numbered buttons.
|
||||
|
||||
**Independent Test**: With multiple pages available, click the next chevron (›) and confirm the library advances one page; click the previous chevron (‹) and confirm it retreats one page.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user is not on the last page, **When** they click the next chevron (›), **Then** the library advances by one page.
|
||||
2. **Given** the user is not on the first page, **When** they click the previous chevron (‹), **Then** the library retreats by one page.
|
||||
3. **Given** the user is on the first page, **When** they view the pagination bar, **Then** the previous chevron (‹) is visually disabled and non-interactive.
|
||||
4. **Given** the user is on the last page, **When** they view the pagination bar, **Then** the next chevron (›) is visually disabled and non-interactive.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Jump to First and Last Page (Priority: P3)
|
||||
|
||||
A user wants to jump directly to the first or last page of the library without stepping through intermediate pages.
|
||||
|
||||
**Why this priority**: First/last navigation is a convenience for large libraries; useful but not essential.
|
||||
|
||||
**Independent Test**: Navigate to any middle page, click the last-page double chevron (»), and confirm the final page loads; click the first-page double chevron («) and confirm page one loads.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user is not on the first page, **When** they click the first-page double chevron («), **Then** the library jumps to page one.
|
||||
2. **Given** the user is not on the last page, **When** they click the last-page double chevron (»), **Then** the library jumps to the final page.
|
||||
3. **Given** the user is on the first page, **When** they view the pagination bar, **Then** the first-page double chevron («) is visually disabled and non-interactive.
|
||||
4. **Given** the user is on the last page, **When** they view the pagination bar, **Then** the last-page double chevron (») is visually disabled and non-interactive.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when there is only one page of images? The entire pagination bar is hidden.
|
||||
- What happens when the current page is in the middle of a large range (e.g. page 7 of 20)? The four visible page buttons centre around the current page where possible.
|
||||
- What happens when the current page is near the start or end of the total range? The window of four buttons anchors to the start or end rather than going out of range.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The pagination bar MUST display up to four numbered page buttons at a time.
|
||||
- **FR-002**: The currently active page button MUST be visually distinguished from inactive page buttons.
|
||||
- **FR-003**: The pagination bar MUST include a previous-page button (‹) and a next-page button (›).
|
||||
- **FR-004**: The pagination bar MUST include a first-page button («) and a last-page button (»).
|
||||
- **FR-005**: The previous (‹) and first-page («) controls MUST be disabled when the user is on page one.
|
||||
- **FR-006**: The next (›) and last-page (») controls MUST be disabled when the user is on the final page.
|
||||
- **FR-007**: The visible window of four page buttons MUST shift to keep the current page always in view.
|
||||
- **FR-008**: The pagination bar MUST be hidden when the total number of pages is one or fewer.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: All six controls («, ‹, up to four page numbers, ›, ») are visible and correctly labelled on any page with more than four total pages.
|
||||
- **SC-002**: Disabled controls are visually distinct and cannot be activated by the user.
|
||||
- **SC-003**: The active page button always reflects the currently displayed page without requiring a page reload.
|
||||
- **SC-004**: Navigating between pages does not introduce any additional loading delay beyond what the existing image fetch already takes.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing library already supports offset-based pagination; this feature changes only the navigation controls, not the underlying data fetching.
|
||||
- The pagination bar is hidden when there is only one page, consistent with common library UX conventions.
|
||||
- The four-button window shifts so the current page is always visible; no ellipsis or overflow indicator is required.
|
||||
- Mobile layout is in scope; all controls must remain usable on small screens.
|
||||
125
specs/018-pagination-controls/tasks.md
Normal file
125
specs/018-pagination-controls/tasks.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Tasks: Pagination Controls Redesign
|
||||
|
||||
**Input**: Design documents from `specs/018-pagination-controls/`
|
||||
**Branch**: `018-pagination-controls`
|
||||
|
||||
**Scope**: Two files change — `library.component.ts` (template, styles, class) and `library.component.spec.ts` (tests). No API, no data model, no new files.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: Baseline verification before touching the component.
|
||||
|
||||
- [X] T001 Confirm existing library component tests pass by running `ng test --include=**/library.component.spec.ts --watch=false` in ui/
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational
|
||||
|
||||
**Purpose**: No blocking infrastructure work required — all three user stories build directly on the existing `LibraryComponent`. Skipped.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Page Number Navigation (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Replace the "Page X of Y" text with up to four clickable numbered page buttons. User can jump directly to any visible page.
|
||||
|
||||
**Independent Test**: With more than four pages of images, four numbered buttons appear; clicking a button loads that page and the button shows as active.
|
||||
|
||||
### Tests for User Story 1 (REQUIRED per §5.1)
|
||||
|
||||
- [X] T002 [US1] Write failing tests for `pageWindow` getter covering: first page (→ [1,2,3,4]), last page (→ last 4), middle page (current in window), total pages < 4 (all shown) in ui/src/app/library/library.component.spec.ts
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T003 [US1] Add `get pageWindow(): number[]` getter to `LibraryComponent` using the sliding-window algorithm from plan.md in ui/src/app/library/library.component.ts
|
||||
- [X] T004 [US1] Add `goToPage(page: number)` method to `LibraryComponent` (navigate via router queryParam, call `load()`) in ui/src/app/library/library.component.ts
|
||||
- [X] T005 [US1] Replace the `<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>` with `*ngFor` numbered page buttons; add `.page-btn` and `.page-btn.active` styles; verify T002 tests pass in ui/src/app/library/library.component.ts
|
||||
|
||||
**Checkpoint**: Four numbered page buttons visible; clicking one loads the correct page; active button is highlighted.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Previous/Next Chevrons (Priority: P2)
|
||||
|
||||
**Goal**: Replace text "← Previous" / "Next →" buttons with ‹ › chevrons that are always rendered but visually disabled and non-interactive when at the first or last page.
|
||||
|
||||
**Independent Test**: On page 1 ‹ is disabled; on last page › is disabled; clicking either on a valid page advances or retreats by one.
|
||||
|
||||
### Tests for User Story 2 (REQUIRED per §5.1)
|
||||
|
||||
- [X] T006 [US2] Write failing tests for ‹ › disabled attribute and non-interactivity: disabled on page 1, disabled on last page, enabled otherwise in ui/src/app/library/library.component.spec.ts
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T007 [US2] Replace the `*ngIf`-gated ← Previous / Next → buttons with always-rendered `<button [disabled]="currentPage === 1">‹</button>` and `<button [disabled]="currentPage === totalPages">›</button>`; add `.pag-btn:disabled` style; verify T006 tests pass in ui/src/app/library/library.component.ts
|
||||
|
||||
**Checkpoint**: ‹ and › always visible; disabled at bounds; single-page step works.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — First/Last Jump Buttons (Priority: P3)
|
||||
|
||||
**Goal**: Add « and » buttons that jump directly to page 1 and the last page, disabled when already there.
|
||||
|
||||
**Independent Test**: From any middle page, « jumps to page 1 and » jumps to the last page; both are disabled when already at the respective bound.
|
||||
|
||||
### Tests for User Story 3 (REQUIRED per §5.1)
|
||||
|
||||
- [X] T008 [US3] Write failing tests for `firstPage()` and `lastPage()` methods and disabled states of « » at page boundaries in ui/src/app/library/library.component.spec.ts
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T009 [US3] Add `firstPage()` and `lastPage()` methods to `LibraryComponent`; add `<button [disabled]="currentPage === 1">«</button>` and `<button [disabled]="currentPage === totalPages">»</button>` to each end of the pagination bar; verify T008 tests pass in ui/src/app/library/library.component.ts
|
||||
|
||||
**Checkpoint**: Full bar renders as `« ‹ [1][2][3][4] › »`; all disabled states correct.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T010 Apply final styles: consistent button sizing, gap spacing, and mobile-friendly layout (flex-wrap or min-width as needed) for the full pagination bar in ui/src/app/library/library.component.ts
|
||||
- [X] T011 Run ESLint and Prettier on ui/src/app/library/ and resolve any issues
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **T001**: Run first — baseline gate
|
||||
- **T002 → T003 → T004 → T005**: Sequential (tests before implementation; each method before its template usage)
|
||||
- **T006 → T007**: Sequential (tests before implementation)
|
||||
- **T008 → T009**: Sequential (tests before implementation)
|
||||
- **T010, T011**: After all story phases complete; can run in either order
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Independent — starts after T001
|
||||
- **US2 (P2)**: Starts after US1 is complete (shares same template section)
|
||||
- **US3 (P3)**: Starts after US2 is complete (adds to the same template section)
|
||||
|
||||
All three stories touch the same two files, so parallel execution is not applicable here.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (User Story 1 only)
|
||||
|
||||
1. T001: Baseline check
|
||||
2. T002–T005: Numbered buttons + goToPage
|
||||
3. **Validate**: Four page buttons work, active state correct
|
||||
4. Defer US2 and US3 if shipping early
|
||||
|
||||
### Full Delivery
|
||||
|
||||
1. T001 baseline → US1 (T002–T005) → US2 (T006–T007) → US3 (T008–T009) → Polish (T010–T011)
|
||||
2. Each story checkpoint validates independence before moving on
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `pageWindow` algorithm: `start = max(1, currentPage-1); end = min(totalPages, start+3); start = max(1, end-3); pages = [start..end]`
|
||||
- No `[P]` markers — all tasks share the same two files and must run sequentially
|
||||
- Entire pagination bar hidden when `totalPages <= 1` (existing behaviour; do not regress)
|
||||
@@ -59,6 +59,7 @@
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"polyfills": ["zone.js", "zone.js/testing"],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"assets": [
|
||||
|
||||
@@ -14,7 +14,13 @@ module.exports = function (config) {
|
||||
jasmineHtmlReporter: { suppressAll: true },
|
||||
coverageReporter: { dir: require('path').join(__dirname, './coverage/reactbin-ui'), subdir: '.', reporters: [{ type: 'html' }, { type: 'text-summary' }] },
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
browsers: ['Chrome'],
|
||||
customLaunchers: {
|
||||
FirefoxHeadless: {
|
||||
base: 'Firefox',
|
||||
flags: ['--headless'],
|
||||
},
|
||||
},
|
||||
browsers: ['FirefoxHeadless'],
|
||||
restartOnFileChange: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -155,15 +155,72 @@ describe('LibraryComponent', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
// ---- Pagination: US1 ----
|
||||
// ---- Pagination: page window (T002) ----
|
||||
|
||||
it('page indicator shows "Page 1 of 2" when totalPages > 1', () => {
|
||||
it('pageWindow returns [1,2,3,4] on page 1 of 20', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 1;
|
||||
fixture.componentInstance.totalPages = 20;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('pageWindow returns [17,18,19,20] on page 20 of 20', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 20;
|
||||
fixture.componentInstance.totalPages = 20;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([17, 18, 19, 20]);
|
||||
});
|
||||
|
||||
it('pageWindow returns [6,7,8,9] on page 7 of 20', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 7;
|
||||
fixture.componentInstance.totalPages = 20;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
it('pageWindow returns all pages when totalPages < 4', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 2;
|
||||
fixture.componentInstance.totalPages = 3;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('pageWindow returns [1] when totalPages is 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 1;
|
||||
fixture.componentInstance.totalPages = 1;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([1]);
|
||||
});
|
||||
|
||||
it('numbered page buttons are rendered (T002)', () => {
|
||||
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');
|
||||
const pageBtns = (fixture.nativeElement as HTMLElement).querySelectorAll('.page-btn');
|
||||
expect(pageBtns.length).toBe(2); // 2 total pages
|
||||
expect(pageBtns[0].textContent?.trim()).toBe('1');
|
||||
expect(pageBtns[1].textContent?.trim()).toBe('2');
|
||||
});
|
||||
|
||||
it('active page button has .active class', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const activeBtn = (fixture.nativeElement as HTMLElement).querySelector('.page-btn.active');
|
||||
expect(activeBtn).not.toBeNull();
|
||||
expect(activeBtn?.textContent?.trim()).toBe('1');
|
||||
});
|
||||
|
||||
it('goToPage() calls imageService.list with correct offset', () => {
|
||||
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.goToPage(2);
|
||||
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
|
||||
});
|
||||
|
||||
it('total count renders with correct number', () => {
|
||||
@@ -175,32 +232,6 @@ describe('LibraryComponent', () => {
|
||||
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);
|
||||
@@ -242,7 +273,58 @@ describe('LibraryComponent', () => {
|
||||
expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0);
|
||||
});
|
||||
|
||||
// ---- Pagination: US2 — URL state ----
|
||||
// ---- Pagination: ‹ › disabled states (T006) ----
|
||||
|
||||
it('prev-btn (‹) is disabled on page 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
|
||||
expect(prevBtn).not.toBeNull();
|
||||
expect(prevBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('next-btn (›) is disabled 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();
|
||||
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
|
||||
expect(nextBtn).not.toBeNull();
|
||||
expect(nextBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('prev-btn (‹) is enabled when not on page 1', () => {
|
||||
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();
|
||||
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
|
||||
expect(prevBtn.disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('next-btn (›) is enabled 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();
|
||||
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
|
||||
expect(nextBtn.disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('both prev and next buttons always rendered when totalPages > 1', () => {
|
||||
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')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ---- Pagination: URL state ----
|
||||
|
||||
it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||
@@ -260,7 +342,6 @@ describe('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);
|
||||
});
|
||||
|
||||
@@ -304,4 +385,69 @@ describe('LibraryComponent', () => {
|
||||
card.click();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/i', 'ShrtImg1']);
|
||||
});
|
||||
|
||||
// ---- Pagination: « » first/last (T008) ----
|
||||
|
||||
it('firstPage() navigates to page 1', () => {
|
||||
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.firstPage();
|
||||
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0);
|
||||
expect(fixture.componentInstance.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('lastPage() navigates to last page', () => {
|
||||
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.lastPage();
|
||||
expect(fixture.componentInstance.currentPage).toBe(fixture.componentInstance.totalPages);
|
||||
});
|
||||
|
||||
it('first-page button («) is disabled on page 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
|
||||
expect(firstBtn).not.toBeNull();
|
||||
expect(firstBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('last-page button (») is disabled 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();
|
||||
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
|
||||
expect(lastBtn).not.toBeNull();
|
||||
expect(lastBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('first-page button («) is enabled when not on page 1', () => {
|
||||
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();
|
||||
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
|
||||
expect(firstBtn.disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('last-page button (») is enabled 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();
|
||||
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
|
||||
expect(lastBtn.disabled).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,9 +90,17 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
|
||||
<!-- 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>
|
||||
<button class="pag-btn first-btn" [disabled]="currentPage === 1" (click)="firstPage()" aria-label="First page">«</button>
|
||||
<button class="pag-btn prev-btn" [disabled]="currentPage === 1" (click)="prevPage()" aria-label="Previous page">‹</button>
|
||||
<button
|
||||
*ngFor="let p of pageWindow"
|
||||
class="pag-btn page-btn"
|
||||
[class.active]="p === currentPage"
|
||||
(click)="goToPage(p)"
|
||||
[attr.aria-current]="p === currentPage ? 'page' : null"
|
||||
>{{ p }}</button>
|
||||
<button class="pag-btn next-btn" [disabled]="currentPage === totalPages" (click)="nextPage()" aria-label="Next page">›</button>
|
||||
<button class="pag-btn last-btn" [disabled]="currentPage === totalPages" (click)="lastPage()" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -130,10 +138,11 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
.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); }
|
||||
.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; }
|
||||
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 6px; margin: 16px 0 24px; flex-wrap: wrap; }
|
||||
.pag-btn { min-width: 36px; height: 36px; padding: 0 10px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 0.95rem; transition: border-color var(--transition), background var(--transition); }
|
||||
.pag-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
||||
.pag-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.page-btn.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
`],
|
||||
})
|
||||
export class LibraryComponent implements OnInit {
|
||||
@@ -225,6 +234,37 @@ export class LibraryComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
get pageWindow(): number[] {
|
||||
let start = Math.max(1, this.currentPage - 1);
|
||||
const end = Math.min(this.totalPages, start + 3);
|
||||
start = Math.max(1, end - 3);
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
firstPage(): void {
|
||||
if (this.currentPage !== 1) {
|
||||
this.currentPage = 1;
|
||||
this.router.navigate([], { queryParams: { page: 1 }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
lastPage(): void {
|
||||
if (this.currentPage !== this.totalPages) {
|
||||
this.currentPage = this.totalPages;
|
||||
this.router.navigate([], { queryParams: { page: this.totalPages }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
|
||||
Reference in New Issue
Block a user