Feat: Add Copy URL button and reusable toast notification system
Detail page now has a "Copy URL" button that copies the image's direct file URL to the clipboard. A toast service (BehaviorSubject-backed, auto-dismissing after 3s) confirms success or failure. ToastComponent is registered at the app root and available to all future features. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"feature_directory":"specs/015-library-pagination"}
|
{"feature_directory":"specs/016-copy-url-toast"}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- SPECKIT START -->
|
<!-- SPECKIT START -->
|
||||||
For additional context about technologies to be used, project structure,
|
For additional context about technologies to be used, project structure,
|
||||||
shell commands, and other important information, read the current plan at
|
shell commands, and other important information, read the current plan at
|
||||||
`specs/015-library-pagination/plan.md`.
|
`specs/016-copy-url-toast/plan.md`.
|
||||||
<!-- SPECKIT END -->
|
<!-- SPECKIT END -->
|
||||||
|
|||||||
34
specs/016-copy-url-toast/checklists/requirements.md
Normal file
34
specs/016-copy-url-toast/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Copy URL & Toast Notifications
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-05-09
|
||||||
|
**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
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Ready to proceed to `/speckit-plan`.
|
||||||
50
specs/016-copy-url-toast/contracts/toast-service.md
Normal file
50
specs/016-copy-url-toast/contracts/toast-service.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Contract: ToastService
|
||||||
|
|
||||||
|
**Location**: `ui/src/app/services/toast.service.ts`
|
||||||
|
**Provided in**: `root` (singleton)
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Toast {
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToastService {
|
||||||
|
// Observable — emits a Toast when one is active, null when none.
|
||||||
|
readonly current$: Observable<Toast | null>;
|
||||||
|
|
||||||
|
// Show a toast. Replaces any currently-visible toast.
|
||||||
|
// duration defaults to 3000ms.
|
||||||
|
show(message: string, type?: 'success' | 'error', duration?: number): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- `show()` emits the toast immediately on `current$`.
|
||||||
|
- After `duration` ms, emits `null` to dismiss.
|
||||||
|
- Calling `show()` again before the timer expires resets the timer (new toast replaces old).
|
||||||
|
- `type` defaults to `'success'`.
|
||||||
|
- `duration` defaults to `3000`.
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In any component:
|
||||||
|
constructor(private toast: ToastService) {}
|
||||||
|
|
||||||
|
async copyUrl() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
this.toast.show('URL copied!');
|
||||||
|
} catch {
|
||||||
|
this.toast.show('Failed to copy URL', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consumer: ToastComponent
|
||||||
|
|
||||||
|
`ToastComponent` subscribes to `current$` via the `async` pipe and renders/hides based on the emitted value. It is placed once in `AppComponent` and is always present in the DOM.
|
||||||
74
specs/016-copy-url-toast/plan.md
Normal file
74
specs/016-copy-url-toast/plan.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Implementation Plan: Copy URL & Toast Notifications
|
||||||
|
|
||||||
|
**Branch**: `016-copy-url-toast` | **Date**: 2026-05-09 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `specs/016-copy-url-toast/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a "Copy URL" button to the image detail page that copies the image's direct file URL to the clipboard, with a reusable toast notification service wired to confirm success or failure. All changes are UI-only; no API changes are required.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript (strict mode), Angular latest stable
|
||||||
|
**Primary Dependencies**: Angular (`@angular/core`, `@angular/common`), RxJS (`BehaviorSubject`), browser Clipboard API (`navigator.clipboard.writeText`)
|
||||||
|
**Storage**: N/A
|
||||||
|
**Testing**: Karma/Jasmine (`ng test`)
|
||||||
|
**Target Platform**: Browser (modern; Clipboard API requires HTTPS — already in place)
|
||||||
|
**Project Type**: Angular standalone SPA
|
||||||
|
**Performance Goals**: Copy action completes in < 100ms perceived latency; toast appears within 300ms of action
|
||||||
|
**Constraints**: TypeScript strict mode, `ChangeDetectionStrategy.OnPush` on all components, no new npm dependencies
|
||||||
|
**Scale/Scope**: Two new files (service + component), two modified files (detail + app component)
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| §2.1 Strict separation of concerns | ✓ PASS | Pure UI change; no API knowledge in UI beyond what's already in `ImageRecord.file_url` |
|
||||||
|
| §2.6 No speculative abstraction | ✓ PASS | Toast service is justified: used immediately by this feature and explicitly planned for reuse (upload confirmation, delete confirmation, filter feedback). Two concrete use cases exist. |
|
||||||
|
| §5.1 Tests alongside implementation | ✓ PASS | Tests required for `ToastService` and the copy button on `DetailComponent` |
|
||||||
|
| §5.2 Test pyramid | ✓ PASS | Unit tests only (no API/DB involved); Karma/Jasmine |
|
||||||
|
| §6 Tech stack | ✓ PASS | Angular, TypeScript strict — no new dependencies |
|
||||||
|
| §7.3 Linting | ✓ PASS | `ng lint` must pass before task is done |
|
||||||
|
| §8 Scope boundaries | ✓ PASS | No multi-user, no embeds, no public sharing infrastructure — just a clipboard copy |
|
||||||
|
|
||||||
|
**Post-design re-check**: No violations. Feature is entirely additive.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/016-copy-url-toast/
|
||||||
|
├── plan.md ← this file
|
||||||
|
├── research.md ← Phase 0 output
|
||||||
|
├── quickstart.md ← Phase 1 output
|
||||||
|
├── contracts/
|
||||||
|
│ └── toast-service.md ← Phase 1 output
|
||||||
|
└── tasks.md ← /speckit-tasks output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code
|
||||||
|
|
||||||
|
```text
|
||||||
|
ui/src/app/
|
||||||
|
├── app.component.ts ← modified: add <app-toast> to template
|
||||||
|
├── services/
|
||||||
|
│ └── toast.service.ts ← new: singleton toast service
|
||||||
|
├── toast/
|
||||||
|
│ └── toast.component.ts ← new: toast display component
|
||||||
|
└── detail/
|
||||||
|
└── detail.component.ts ← modified: add Copy URL button + inject ToastService
|
||||||
|
|
||||||
|
ui/src/app/services/
|
||||||
|
toast.service.spec.ts ← new: unit tests for ToastService
|
||||||
|
|
||||||
|
ui/src/app/toast/
|
||||||
|
toast.component.spec.ts ← new: unit tests for ToastComponent
|
||||||
|
|
||||||
|
ui/src/app/detail/
|
||||||
|
detail.component.spec.ts ← modified: tests for copy button behaviour
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Single-project Angular SPA. Toast service lives in `services/` alongside `ImageService` and `TagService`. Toast component gets its own `toast/` directory following the existing component-per-directory convention.
|
||||||
33
specs/016-copy-url-toast/quickstart.md
Normal file
33
specs/016-copy-url-toast/quickstart.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Quickstart: Copy URL & Toast Notifications
|
||||||
|
|
||||||
|
## Happy Path — Copy URL
|
||||||
|
|
||||||
|
1. Open any image detail page (e.g. `http://localhost:4200/images/{id}`).
|
||||||
|
2. Confirm a "Copy URL" button is visible.
|
||||||
|
3. Click "Copy URL".
|
||||||
|
4. Confirm a success toast appears ("URL copied!" or similar) and then disappears automatically.
|
||||||
|
5. Paste into a text editor — confirm the pasted value is the full image file URL.
|
||||||
|
|
||||||
|
## Happy Path — Toast Auto-Dismiss
|
||||||
|
|
||||||
|
1. Click "Copy URL".
|
||||||
|
2. Confirm the toast appears.
|
||||||
|
3. Do not interact — wait ~3 seconds.
|
||||||
|
4. Confirm the toast disappears on its own.
|
||||||
|
|
||||||
|
## Edge Case — Clipboard Unavailable
|
||||||
|
|
||||||
|
1. In Firefox, navigate to `about:config` and set `dom.events.asyncClipboard.clipboardItem` to `false` (or test with a non-HTTPS localhost where clipboard API may be blocked).
|
||||||
|
2. Click "Copy URL".
|
||||||
|
3. Confirm an error toast appears (e.g. "Failed to copy URL") and auto-dismisses.
|
||||||
|
|
||||||
|
## Edge Case — Rapid Clicks
|
||||||
|
|
||||||
|
1. Click "Copy URL" three times quickly.
|
||||||
|
2. Confirm only one toast is visible at a time (new toast replaces old, no overlapping stack).
|
||||||
|
|
||||||
|
## Regression — Other Pages
|
||||||
|
|
||||||
|
1. Navigate to the library (`/`), upload page (`/upload`), tags page (`/tags`).
|
||||||
|
2. Confirm no toast or copy button is visible on these pages.
|
||||||
|
3. Confirm existing functionality is unaffected.
|
||||||
55
specs/016-copy-url-toast/research.md
Normal file
55
specs/016-copy-url-toast/research.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Research: Copy URL & Toast Notifications
|
||||||
|
|
||||||
|
## Decision 1: Toast Service Architecture
|
||||||
|
|
||||||
|
**Decision**: `BehaviorSubject<Toast | null>` singleton service, one active toast at a time — new toasts replace the current one.
|
||||||
|
|
||||||
|
**Rationale**: The simplest approach that satisfies FR-007 (reusable from anywhere) and FR-008 (multiple toasts don't overlap illegibly). A queue adds complexity with no meaningful UX benefit for this app's usage pattern (copy URL, upload confirm, etc. — actions that don't overlap in practice). Replacing the current toast on rapid successive calls is acceptable and visually cleaner than a stack. The `BehaviorSubject` integrates naturally with Angular's `async` pipe and OnPush change detection.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `Subject` (not `BehaviorSubject`): Late subscribers miss toasts that already fired. Rejected — component may subscribe after service emits if change detection is deferred.
|
||||||
|
- Toast queue (array): Adds observable complexity and UI layout decisions. Rejected — over-engineered for this use case.
|
||||||
|
- Angular CDK Overlay: Official but heavy. Pulls in CDK dependency for a feature that needs ~30 lines of code. Rejected per §2.6 (no speculative abstraction) and §6 (no new dependencies).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 2: Clipboard API Usage
|
||||||
|
|
||||||
|
**Decision**: `navigator.clipboard.writeText(url)` — no polyfill, no fallback to `document.execCommand`.
|
||||||
|
|
||||||
|
**Rationale**: `execCommand('copy')` is deprecated and removed in some browsers. The Clipboard API is supported in all modern browsers (Chrome 66+, Firefox 63+, Safari 13.1+). The app already requires HTTPS in production (Let's Encrypt via cert-manager), which satisfies the Clipboard API's secure context requirement. On failure (permission denied, API unavailable), catch the rejected Promise and show an error toast.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `execCommand('copy')` fallback: Deprecated, inconsistent, adds code complexity. The failure path (error toast) covers the rare unavailability case more cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 3: What URL to Copy
|
||||||
|
|
||||||
|
**Decision**: Copy `image.file_url` as-is (the direct image file URL).
|
||||||
|
|
||||||
|
**Rationale**: `file_url` is the CDN URL in production (e.g. `https://cdn.reactbin.juggalol.com/…`) — already absolute. In development it is relative (`/api/v1/images/{id}/file`); for dev use, prepend `window.location.origin`. The direct file URL is the right thing to share for a reaction image library: it embeds inline when pasted into Discord/Slack without requiring a click-through.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Detail page URL (`/images/{id}`): The user can already copy this from the browser address bar. The file URL is the value-add.
|
||||||
|
- Always prepend `window.location.origin`: Works for both environments, adds a guard. Included as a defensive measure for the dev case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 4: Toast Positioning
|
||||||
|
|
||||||
|
**Decision**: Fixed position, bottom-center of the viewport.
|
||||||
|
|
||||||
|
**Rationale**: Bottom-center is less intrusive than top-right for a brief confirmation toast. It doesn't overlap the image or the copy button. `pointer-events: none` ensures it never blocks interaction.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Top-right: Common convention (Material, Bootstrap) but overlaps the header/nav area in this layout.
|
||||||
|
- Top-center: Similar issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision 5: OnPush compatibility
|
||||||
|
|
||||||
|
**Decision**: `ToastComponent` uses `ChangeDetectionStrategy.OnPush` with the `async` pipe consuming `toastService.current$`. Angular's `async` pipe calls `markForCheck()` automatically when the observable emits, making it fully compatible with OnPush.
|
||||||
|
|
||||||
|
**Rationale**: Consistent with all other components in the project. No manual `markForCheck()` calls needed in `ToastComponent`.
|
||||||
76
specs/016-copy-url-toast/spec.md
Normal file
76
specs/016-copy-url-toast/spec.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Feature Specification: Copy URL & Toast Notifications
|
||||||
|
|
||||||
|
**Feature Branch**: `016-copy-url-toast`
|
||||||
|
**Created**: 2026-05-09
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Copy Image URL from Detail Page (Priority: P1)
|
||||||
|
|
||||||
|
A user viewing an image on the detail page wants to share the direct link to that image. They click a "Copy URL" button and the image's direct URL is instantly copied to their clipboard, ready to paste anywhere.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core feature and the primary user value. Everything else builds on it.
|
||||||
|
|
||||||
|
**Independent Test**: Open any image detail page. Click the "Copy URL" button. Paste into a text editor — confirm the pasted value is the direct URL to that image.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user is on an image detail page, **When** they click "Copy URL", **Then** the image's direct URL is copied to their clipboard.
|
||||||
|
2. **Given** a user clicks "Copy URL", **When** the copy succeeds, **Then** a confirmation toast appears briefly and disappears on its own.
|
||||||
|
3. **Given** a user clicks "Copy URL", **When** the clipboard is unavailable (e.g. browser denies permission), **Then** a toast appears indicating the copy failed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Reusable Toast Notification System (Priority: P2)
|
||||||
|
|
||||||
|
Any part of the application can trigger a brief, non-blocking notification (toast) to confirm an action or surface an error. The toast appears, persists for a short time, then disappears automatically without user interaction.
|
||||||
|
|
||||||
|
**Why this priority**: The toast infrastructure is needed by US1 and is designed as a foundation for future features (e.g. upload confirmation, filter saved, delete confirmed).
|
||||||
|
|
||||||
|
**Independent Test**: Trigger a toast programmatically. Confirm it appears with the correct message, then disappears automatically after a few seconds without any user interaction.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a toast is triggered, **When** it appears, **Then** it displays the provided message and is visible above other content.
|
||||||
|
2. **Given** a toast is visible, **When** sufficient time passes, **Then** it disappears automatically without user interaction.
|
||||||
|
3. **Given** multiple toasts are triggered in quick succession, **When** they appear, **Then** they stack or queue without overlapping illegibly.
|
||||||
|
4. **Given** a toast is visible, **When** the user interacts with the rest of the page, **Then** the toast does not block or intercept those interactions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the clipboard API is not available or permission is denied? → Show an error toast.
|
||||||
|
- What happens if the user clicks "Copy URL" multiple times rapidly? → Each click copies and shows a toast; toasts queue or stack cleanly.
|
||||||
|
- What happens on a very long URL? → URL is copied in full; toast message is fixed (not the URL itself).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The image detail page MUST display a "Copy URL" button.
|
||||||
|
- **FR-002**: Clicking "Copy URL" MUST copy the image's direct URL to the system clipboard.
|
||||||
|
- **FR-003**: A success toast MUST appear after a successful copy, confirming the action to the user.
|
||||||
|
- **FR-004**: A failure toast MUST appear if the copy cannot be completed (e.g. clipboard permission denied).
|
||||||
|
- **FR-005**: Toasts MUST disappear automatically after a fixed duration without requiring user interaction.
|
||||||
|
- **FR-006**: Toasts MUST NOT block user interaction with the rest of the page.
|
||||||
|
- **FR-007**: The toast system MUST be reusable — any part of the application must be able to trigger a toast with a custom message.
|
||||||
|
- **FR-008**: Multiple toasts triggered in quick succession MUST display without overlapping illegibly.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: A user can copy an image URL in a single click with no additional steps.
|
||||||
|
- **SC-002**: Toast confirmation appears within 300ms of the copy action completing.
|
||||||
|
- **SC-003**: Toasts disappear automatically within 5 seconds of appearing.
|
||||||
|
- **SC-004**: The toast system can be triggered from any page or component without modifying the toast component itself.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The image's direct URL is already available on the detail page (it is — currently displayed or derivable from the current route and API response).
|
||||||
|
- Users are on modern browsers with Clipboard API support; graceful degradation covers the failure case via an error toast.
|
||||||
|
- One toast variant is sufficient for v1: a simple text message with success/error styling. No actions, no dismiss button required.
|
||||||
|
- Toast duration of approximately 3 seconds is appropriate (standard convention).
|
||||||
|
- The detail page already exists; this feature adds to it without redesigning it.
|
||||||
94
specs/016-copy-url-toast/tasks.md
Normal file
94
specs/016-copy-url-toast/tasks.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Tasks: Copy URL & Toast Notifications
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/016-copy-url-toast/`
|
||||||
|
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/toast-service.md ✅, quickstart.md ✅
|
||||||
|
|
||||||
|
**Tests**: Tests accompany each implementation task per §5.1. All changes are in `ui/src/app/`.
|
||||||
|
|
||||||
|
**Organization**: No project setup needed — Angular project exists. The toast infrastructure (US2) must be built before the copy URL feature (US1) can use it, so phases follow implementation dependency order rather than spec priority order.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Toast Infrastructure — User Story 2 (Foundational)
|
||||||
|
|
||||||
|
**Goal**: Build the reusable `ToastService` and `ToastComponent` that US1 and all future features depend on.
|
||||||
|
|
||||||
|
**Independent Test**: In the browser console, call `toastService.show('Hello!')` from any page — confirm a toast appears at the bottom of the screen for ~3 seconds then disappears. Confirm no interaction with the rest of the UI is blocked.
|
||||||
|
|
||||||
|
- [X] T001 [US2] Write tests in `ui/src/app/services/toast.service.spec.ts` covering: (1) `show()` emits a `Toast` object on `current$` with the correct `message` and `type`; (2) after the duration elapses, `current$` emits `null`; (3) `type` defaults to `'success'` when not provided; (4) calling `show()` a second time before the first timer fires replaces the active toast. Run `ng test` and confirm new tests FAIL.
|
||||||
|
|
||||||
|
- [X] T002 [US2] Create `ui/src/app/services/toast.service.ts`: (a) define `export interface Toast { message: string; type: 'success' | 'error'; }`; (b) `@Injectable({ providedIn: 'root' })` class with a private `BehaviorSubject<Toast | null>(null)`; (c) expose `readonly current$: Observable<Toast | null>` from the subject; (d) implement `show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void` — emits the toast immediately, then calls `setTimeout(() => this.subject.next(null), duration)` (store the timer handle and `clearTimeout` it at the start of `show()` so rapid calls replace correctly). Run `ng test` and confirm T001 tests pass.
|
||||||
|
|
||||||
|
- [X] T003 [P] [US2] Write tests in `ui/src/app/toast/toast.component.spec.ts` covering: (1) when `ToastService.current$` emits a `{ message: 'Done', type: 'success' }` toast, a `.toast` element is rendered containing "Done"; (2) the element has the CSS class `success`; (3) when type is `'error'`, the element has class `error`; (4) when `current$` emits `null`, no `.toast` element is present. Run `ng test` and confirm new tests FAIL.
|
||||||
|
|
||||||
|
- [X] T004 [US2] Create `ui/src/app/toast/toast.component.ts`: (a) standalone component, `selector: 'app-toast'`, `ChangeDetectionStrategy.OnPush`, imports `[CommonModule]`; (b) inject `ToastService` as public; (c) template: `<div *ngIf="toastService.current$ | async as toast" class="toast" [class.success]="toast.type === 'success'" [class.error]="toast.type === 'error'">{{ toast.message }}</div>`; (d) styles: `.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: var(--radius); font-size: 0.9rem; pointer-events: none; z-index: 1000; white-space: nowrap; }` with `.success { background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); }` and `.error { background: var(--danger); color: var(--danger-text); }`. Run `ng test` and confirm T003 tests pass.
|
||||||
|
|
||||||
|
- [X] T005 [US2] Register `ToastComponent` in `ui/src/app/app.component.ts`: add `ToastComponent` to the `imports` array; add `<app-toast></app-toast>` to the template after `<router-outlet />`; add a test to `ui/src/app/app.component.spec.ts` asserting that an `app-toast` element is present in the rendered output. Confirm all existing AppComponent tests still pass and `ng build --configuration development` succeeds.
|
||||||
|
|
||||||
|
**Checkpoint**: Toast infrastructure complete. Any component in the app can now inject `ToastService` and call `show()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Copy URL Button — User Story 1 (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Add a "Copy URL" button to the image detail page. One click copies the direct image file URL to the clipboard and shows a confirmation toast.
|
||||||
|
|
||||||
|
**Independent Test**: Open any image detail page. Click "Copy URL". Confirm a success toast appears. Paste into a text editor and confirm the pasted value is the full image file URL. Then simulate a clipboard failure (e.g. revoke clipboard permission) and confirm an error toast appears instead.
|
||||||
|
|
||||||
|
- [X] T006 [US1] Write tests in `ui/src/app/detail/detail.component.spec.ts` covering: (1) a "Copy URL" button (`.copy-url-btn`) is present in the DOM when an image is loaded; (2) clicking it calls `navigator.clipboard.writeText` with the image's `file_url` when `file_url` is already absolute (starts with `http`); (3) when `file_url` is relative (starts with `/`), `writeText` is called with `window.location.origin + file_url`; (4) when `writeText` resolves, `toastService.show` is called with a success message; (5) when `writeText` rejects, `toastService.show` is called with an error message and type `'error'`. Spy on `navigator.clipboard.writeText` using `spyOn(navigator.clipboard, 'writeText')` returning `Promise.resolve()` / `Promise.reject()` as appropriate. Run `ng test` and confirm new tests FAIL.
|
||||||
|
|
||||||
|
- [X] T007 [US1] Update `ui/src/app/detail/detail.component.ts`: (a) inject `ToastService` (add to constructor); (b) add `copyUrl(): void` method — resolves the URL as `this.image!.file_url.startsWith('http') ? this.image!.file_url : window.location.origin + this.image!.file_url`, then calls `navigator.clipboard.writeText(url).then(() => this.toastService.show('URL copied!')).catch(() => this.toastService.show('Failed to copy URL', 'error'))`; (c) add a `<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>` to the template inside the `*ngIf="image && !loading"` block, placed below the image and above the tags section; (d) style: `padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0;` with hover `border-color: var(--border-focus)`. Run `ng test` and confirm T006 tests pass.
|
||||||
|
|
||||||
|
**Checkpoint**: US1 complete. Detail page has a working Copy URL button with toast feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T008 Run `ng lint` on all modified and created files in `ui/src/app/`; fix any issues. Confirm `ng test` passes with all new and existing tests green. Manually verify all `quickstart.md` scenarios in a browser: happy path copy, auto-dismiss, clipboard error, rapid clicks, regression on other pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- T001 before T002 (write failing tests before service implementation)
|
||||||
|
- T002 before T003/T004 (service must exist for component tests to import it)
|
||||||
|
- T003 before T004 (write failing tests before component implementation)
|
||||||
|
- T004 before T005 (component must exist before registering in app)
|
||||||
|
- T005 before T006 (toast infrastructure must be complete before copy URL tests)
|
||||||
|
- T006 before T007 (write failing tests before detail component changes)
|
||||||
|
- T007 before T008 (implementation before polish)
|
||||||
|
|
||||||
|
### Execution Order Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: T001 (US2: failing ToastService tests)
|
||||||
|
Step 2: T002 (US2: ToastService implementation — tests turn green)
|
||||||
|
Step 3: T003 (US2: failing ToastComponent tests) [can parallel with T002 if needed]
|
||||||
|
Step 4: T004 (US2: ToastComponent implementation — tests turn green)
|
||||||
|
Step 5: T005 (US2: wire ToastComponent into AppComponent)
|
||||||
|
Step 6: T006 (US1: failing copy URL tests)
|
||||||
|
Step 7: T007 (US1: copy URL implementation — tests turn green)
|
||||||
|
Step 8: T008 (Polish: lint + manual verification)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (US1 — single story delivers full feature value)
|
||||||
|
|
||||||
|
1. T001–T005 — toast infrastructure
|
||||||
|
2. T006–T007 — copy URL button
|
||||||
|
3. **STOP and VALIDATE**: open browser, click Copy URL, confirm toast, paste to verify URL
|
||||||
|
4. T008 — polish
|
||||||
|
5. Deploy
|
||||||
|
|
||||||
|
### Note on Priority Ordering
|
||||||
|
|
||||||
|
US2 (toast system) is listed as P2 in the spec because it is infrastructure rather than the end-user-visible feature. However it is a hard implementation prerequisite for US1 (P1). Phases follow implementation dependency order: US2 infrastructure is built first, US1 feature consumes it second.
|
||||||
@@ -83,6 +83,14 @@ describe('AppComponent', () => {
|
|||||||
expect(link.getAttribute('href')).toBe('/');
|
expect(link.getAttribute('href')).toBe('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders app-toast element', () => {
|
||||||
|
authSpy.isAuthenticated.and.returnValue(false);
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const toast = (fixture.nativeElement as HTMLElement).querySelector('app-toast');
|
||||||
|
expect(toast).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('header height is 48px', () => {
|
it('header height is 48px', () => {
|
||||||
authSpy.isAuthenticated.and.returnValue(true);
|
authSpy.isAuthenticated.and.returnValue(true);
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import { Component } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, RouterLink, RouterOutlet } from '@angular/router';
|
import { Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
|
import { ToastComponent } from './toast/toast.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, RouterOutlet],
|
imports: [CommonModule, RouterLink, RouterOutlet, ToastComponent],
|
||||||
template: `
|
template: `
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<a routerLink="/" class="app-name">Reactbin</a>
|
<a routerLink="/" class="app-name">Reactbin</a>
|
||||||
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
|
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
|
||||||
</header>
|
</header>
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
<app-toast></app-toast>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
:host { display: block; }
|
:host { display: block; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing';
|
|||||||
import { of, throwError, Subject } from 'rxjs';
|
import { of, throwError, Subject } from 'rxjs';
|
||||||
import { DetailComponent } from './detail.component';
|
import { DetailComponent } from './detail.component';
|
||||||
import { ImageService } from '../services/image.service';
|
import { ImageService } from '../services/image.service';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
import { routes } from '../app.routes';
|
import { routes } from '../app.routes';
|
||||||
|
|
||||||
const MOCK_IMAGE = {
|
const MOCK_IMAGE = {
|
||||||
@@ -13,6 +14,7 @@ const MOCK_IMAGE = {
|
|||||||
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
|
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
|
||||||
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
created_at: '2026-01-01T00:00:00Z', tags: ['cat', 'funny'],
|
||||||
};
|
};
|
||||||
|
const MOCK_IMAGE_ABS = { ...MOCK_IMAGE, file_url: 'https://cdn.example.com/img-1.jpg' };
|
||||||
|
|
||||||
describe('DetailComponent', () => {
|
describe('DetailComponent', () => {
|
||||||
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
|
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
|
||||||
@@ -143,4 +145,51 @@ describe('DetailComponent', () => {
|
|||||||
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||||||
expect(imgEl.src).toBe(before);
|
expect(imgEl.src).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Copy URL ----
|
||||||
|
|
||||||
|
it('Copy URL button is present when image is loaded', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.copy-url-btn')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copyUrl() calls writeText with file_url when it is already absolute', async () => {
|
||||||
|
const { component } = setup('img-1', of(MOCK_IMAGE_ABS));
|
||||||
|
const toast = TestBed.inject(ToastService);
|
||||||
|
spyOn(toast, 'show');
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||||
|
component.copyUrl();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(MOCK_IMAGE_ABS.file_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copyUrl() prepends window.location.origin when file_url is relative', async () => {
|
||||||
|
const { component } = setup();
|
||||||
|
const toast = TestBed.inject(ToastService);
|
||||||
|
spyOn(toast, 'show');
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||||
|
component.copyUrl();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.origin + MOCK_IMAGE.file_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copyUrl() calls toast.show with success message on writeText resolve', async () => {
|
||||||
|
const { component } = setup();
|
||||||
|
const toast = TestBed.inject(ToastService);
|
||||||
|
spyOn(toast, 'show');
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||||
|
component.copyUrl();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(toast.show).toHaveBeenCalledWith('URL copied!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copyUrl() calls toast.show with error message on writeText reject', async () => {
|
||||||
|
const { component } = setup();
|
||||||
|
const toast = TestBed.inject(ToastService);
|
||||||
|
spyOn(toast, 'show');
|
||||||
|
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied')));
|
||||||
|
component.copyUrl();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(toast.show).toHaveBeenCalledWith('Failed to copy URL', 'error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { ImageRecord, ImageService } from '../services/image.service';
|
import { ImageRecord, ImageService } from '../services/image.service';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
|
|
||||||
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">🔗</text></svg>`;
|
const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500"><rect width="800" height="500" fill="%23111"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="48" fill="%23444">🔗</text></svg>`;
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
|||||||
(error)="onImgError($event)"
|
(error)="onImgError($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>
|
||||||
|
|
||||||
<section class="tags-section">
|
<section class="tags-section">
|
||||||
<h3>Tags</h3>
|
<h3>Tags</h3>
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
@@ -139,6 +142,10 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
|||||||
.add-tag input:focus { outline: none; border-color: var(--border-focus); }
|
.add-tag input:focus { outline: none; border-color: var(--border-focus); }
|
||||||
.delete-btn { padding: 10px 24px; background: var(--danger); color: var(--danger-text); border: none; border-radius: var(--radius); cursor: pointer; margin-left: auto; }
|
.delete-btn { padding: 10px 24px; background: var(--danger); color: var(--danger-text); border: none; border-radius: var(--radius); cursor: pointer; margin-left: auto; }
|
||||||
|
|
||||||
|
/* Copy URL */
|
||||||
|
.copy-url-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; margin: 12px 0; transition: border-color var(--transition); }
|
||||||
|
.copy-url-btn:hover { border-color: var(--border-focus); }
|
||||||
|
|
||||||
/* Delete dialog */
|
/* Delete dialog */
|
||||||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||||
.dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; }
|
.dialog { background: var(--surface); padding: 32px; border-radius: 10px; text-align: center; }
|
||||||
@@ -163,6 +170,7 @@ export class DetailComponent implements OnInit {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
public router: Router,
|
public router: Router,
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
|
private toastService: ToastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -235,6 +243,16 @@ export class DetailComponent implements OnInit {
|
|||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copyUrl(): void {
|
||||||
|
if (!this.image) return;
|
||||||
|
const url = this.image.file_url.startsWith('http')
|
||||||
|
? this.image.file_url
|
||||||
|
: window.location.origin + this.image.file_url;
|
||||||
|
navigator.clipboard.writeText(url)
|
||||||
|
.then(() => this.toastService.show('URL copied!'))
|
||||||
|
.catch(() => this.toastService.show('Failed to copy URL', 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
goBack(): void { this.router.navigate(['/']); }
|
goBack(): void { this.router.navigate(['/']); }
|
||||||
|
|
||||||
onImgError(event: Event): void {
|
onImgError(event: Event): void {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
|||||||
template: `
|
template: `
|
||||||
<div class="library">
|
<div class="library">
|
||||||
<header>
|
<header>
|
||||||
<h1><a class="home-link" (click)="router.navigate(['/'])">Reactbin</a></h1>
|
<h1><a class="home-link" routerLink="/" [queryParams]="{}">Reactbin</a></h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a routerLink="/tags" class="tags-link">Browse tags</a>
|
<a routerLink="/tags" class="tags-link">Browse tags</a>
|
||||||
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
|
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
|
||||||
|
|||||||
51
ui/src/app/services/toast.service.spec.ts
Normal file
51
ui/src/app/services/toast.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { Toast, ToastService } from './toast.service';
|
||||||
|
|
||||||
|
describe('ToastService', () => {
|
||||||
|
let service: ToastService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(ToastService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show() emits a toast with correct message and type', (done) => {
|
||||||
|
service.current$.subscribe((toast) => {
|
||||||
|
if (toast) {
|
||||||
|
expect(toast.message).toBe('Hello!');
|
||||||
|
expect(toast.type).toBe('error');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
service.show('Hello!', 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('type defaults to success when not provided', (done) => {
|
||||||
|
service.current$.subscribe((toast) => {
|
||||||
|
if (toast) {
|
||||||
|
expect(toast.type).toBe('success');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
service.show('Default type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('current$ emits null after the duration elapses', fakeAsync(() => {
|
||||||
|
const emitted: (string | null)[] = [];
|
||||||
|
service.current$.subscribe((t: Toast | null) => emitted.push(t ? t.message : null));
|
||||||
|
service.show('Auto-dismiss', 'success', 500);
|
||||||
|
tick(500);
|
||||||
|
expect(emitted).toContain(null);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('calling show() again before timer fires replaces the active toast', fakeAsync(() => {
|
||||||
|
const messages: (string | null)[] = [];
|
||||||
|
service.current$.subscribe((t: Toast | null) => messages.push(t ? t.message : null));
|
||||||
|
service.show('First', 'success', 1000);
|
||||||
|
tick(200);
|
||||||
|
service.show('Second', 'success', 1000);
|
||||||
|
tick(0);
|
||||||
|
const nonNull = messages.filter((m) => m !== null);
|
||||||
|
expect(nonNull[nonNull.length - 1]).toBe('Second');
|
||||||
|
}));
|
||||||
|
});
|
||||||
25
ui/src/app/services/toast.service.ts
Normal file
25
ui/src/app/services/toast.service.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ToastService {
|
||||||
|
private readonly subject = new BehaviorSubject<Toast | null>(null);
|
||||||
|
readonly current$: Observable<Toast | null> = this.subject.asObservable();
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
show(message: string, type: 'success' | 'error' = 'success', duration = 3000): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
this.subject.next({ message, type });
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.subject.next(null);
|
||||||
|
this.timer = null;
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
ui/src/app/toast/toast.component.spec.ts
Normal file
49
ui/src/app/toast/toast.component.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { ToastComponent } from './toast.component';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
|
|
||||||
|
describe('ToastComponent', () => {
|
||||||
|
let toastSvc: jasmine.SpyObj<ToastService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
toastSvc = jasmine.createSpyObj('ToastService', [], { current$: of(null) });
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ToastComponent],
|
||||||
|
providers: [{ provide: ToastService, useValue: toastSvc }],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a .toast element with the correct message when current$ emits a toast', async () => {
|
||||||
|
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Done', type: 'success' as const }) });
|
||||||
|
const fixture = TestBed.createComponent(ToastComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||||
|
expect(el).not.toBeNull();
|
||||||
|
expect(el?.textContent?.trim()).toBe('Done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the success CSS class when type is success', async () => {
|
||||||
|
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'OK', type: 'success' as const }) });
|
||||||
|
const fixture = TestBed.createComponent(ToastComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||||
|
expect(el?.classList.contains('success')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the error CSS class when type is error', async () => {
|
||||||
|
Object.defineProperty(toastSvc, 'current$', { value: of({ message: 'Fail', type: 'error' as const }) });
|
||||||
|
const fixture = TestBed.createComponent(ToastComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||||
|
expect(el?.classList.contains('error')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when current$ emits null', () => {
|
||||||
|
const fixture = TestBed.createComponent(ToastComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const el = (fixture.nativeElement as HTMLElement).querySelector('.toast');
|
||||||
|
expect(el).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
ui/src/app/toast/toast.component.ts
Normal file
44
ui/src/app/toast/toast.component.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-toast',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
*ngIf="toastService.current$ | async as toast"
|
||||||
|
class="toast"
|
||||||
|
[class.success]="toast.type === 'success'"
|
||||||
|
[class.error]="toast.type === 'error'"
|
||||||
|
>{{ toast.message }}</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class ToastComponent {
|
||||||
|
constructor(public toastService: ToastService) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user