Compare commits
6 Commits
v1.2.0
...
b094389131
| Author | SHA1 | Date | |
|---|---|---|---|
| b094389131 | |||
| 7d49c12ce2 | |||
| 443887ea93 | |||
| e4bfe13072 | |||
| 0a76bb03b5 | |||
| 8cbf1e527a |
@@ -1 +1 @@
|
||||
{"feature_directory":"specs/015-library-pagination"}
|
||||
{"feature_directory":"specs/016-copy-url-toast"}
|
||||
|
||||
@@ -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/015-library-pagination/plan.md`.
|
||||
`specs/016-copy-url-toast/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
initContainers:
|
||||
- name: migrate
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.2.0
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.2.1
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
workingDir: /app
|
||||
envFrom:
|
||||
@@ -26,7 +26,7 @@ spec:
|
||||
runAsUser: 1001
|
||||
containers:
|
||||
- name: api
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.2.0
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.2.1
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: ui
|
||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.2.0
|
||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.2.1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
|
||||
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('/');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
authSpy.isAuthenticated.and.returnValue(true);
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
|
||||
@@ -2,17 +2,19 @@ import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { ToastComponent } from './toast/toast.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterOutlet],
|
||||
imports: [CommonModule, RouterLink, RouterOutlet, ToastComponent],
|
||||
template: `
|
||||
<header class="app-header">
|
||||
<a routerLink="/" class="app-name">Reactbin</a>
|
||||
<button *ngIf="auth.isAuthenticated()" class="logout-btn" (click)="onLogout()">Sign out</button>
|
||||
</header>
|
||||
<router-outlet />
|
||||
<app-toast></app-toast>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of, throwError, Subject } from 'rxjs';
|
||||
import { DetailComponent } from './detail.component';
|
||||
import { ImageService } from '../services/image.service';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
const MOCK_IMAGE = {
|
||||
@@ -13,6 +14,7 @@ const MOCK_IMAGE = {
|
||||
thumbnail_key: null, file_url: '/api/v1/images/img-1/file', thumbnail_url: null,
|
||||
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', () => {
|
||||
function setup(imageId = 'img-1', imageResponse = of(MOCK_IMAGE)) {
|
||||
@@ -143,4 +145,52 @@ describe('DetailComponent', () => {
|
||||
fixture.componentInstance.onImgError({ target: imgEl } as unknown as Event);
|
||||
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();
|
||||
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 { ImageRecord, ImageService } from '../services/image.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>`;
|
||||
|
||||
@@ -54,6 +55,8 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
(error)="onImgError($event)"
|
||||
/>
|
||||
|
||||
<button class="copy-url-btn" (click)="copyUrl()">Copy URL</button>
|
||||
|
||||
<section class="tags-section">
|
||||
<h3>Tags</h3>
|
||||
<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); }
|
||||
.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 */
|
||||
.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; }
|
||||
@@ -163,6 +170,7 @@ export class DetailComponent implements OnInit {
|
||||
private route: ActivatedRoute,
|
||||
public router: Router,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -235,6 +243,16 @@ export class DetailComponent implements OnInit {
|
||||
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(['/']); }
|
||||
|
||||
onImgError(event: Event): void {
|
||||
|
||||
@@ -8,12 +8,10 @@ import { ImageService } from '../services/image.service';
|
||||
import { routes } from '../app.routes';
|
||||
|
||||
function makeActivatedRoute(queryParams: Record<string, string> = {}) {
|
||||
const paramMap = { get: (key: string) => queryParams[key] ?? null };
|
||||
return {
|
||||
snapshot: {
|
||||
queryParamMap: {
|
||||
get: (key: string) => queryParams[key] ?? null,
|
||||
},
|
||||
},
|
||||
snapshot: { queryParamMap: paramMap },
|
||||
queryParamMap: of(paramMap),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, ActivatedRoute } from '@angular/router';
|
||||
import { Subject, debounceTime, distinctUntilChanged, share, timer } from 'rxjs';
|
||||
import { Subject, debounceTime, distinctUntilChanged, share, skip, timer } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { ImageRecord, ImageService } from '../services/image.service';
|
||||
import { TagService } from '../services/tag.service';
|
||||
@@ -21,7 +21,7 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
template: `
|
||||
<div class="library">
|
||||
<header>
|
||||
<h1>Reactbin</h1>
|
||||
<h1><a class="home-link" routerLink="/" [queryParams]="{}">Reactbin</a></h1>
|
||||
<div class="header-actions">
|
||||
<a routerLink="/tags" class="tags-link">Browse tags</a>
|
||||
<button class="upload-btn" (click)="router.navigate(['/upload'])">Upload</button>
|
||||
@@ -118,7 +118,9 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
.image-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
||||
.image-card img { width: 100%; height: 160px; object-fit: cover; display: block; }
|
||||
.card-skeleton { height: 200px; }
|
||||
.tag-row { padding: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag-row { padding: 6px; display: flex; flex-wrap: nowrap; gap: 4px; overflow: hidden; position: relative; }
|
||||
.tag-row::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 2rem; background: linear-gradient(to right, transparent, var(--surface)); pointer-events: none; }
|
||||
.home-link { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
.empty-state { text-align: center; padding: 60px 0; color: var(--text-muted); }
|
||||
.empty-icon { display: block; font-size: 2rem; margin-bottom: 12px; }
|
||||
.upload-link { display: inline-block; margin-top: 16px; color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
@@ -166,6 +168,21 @@ export class LibraryComponent implements OnInit {
|
||||
this.currentPage = Math.max(1, parseInt(pageParam, 10) || 1);
|
||||
}
|
||||
this.load();
|
||||
this.route.queryParamMap.pipe(skip(1)).subscribe((params) => {
|
||||
const newPage = params.get('page') ? Math.max(1, parseInt(params.get('page')!, 10) || 1) : 1;
|
||||
const newTagsParam = params.get('tags');
|
||||
const newTags = newTagsParam
|
||||
? newTagsParam.split(',').map((t) => t.trim()).filter((t) => t.length > 0)
|
||||
: [];
|
||||
const pageChanged = newPage !== this.currentPage;
|
||||
const tagsChanged = JSON.stringify(newTags) !== JSON.stringify(this.activeFilters);
|
||||
if (pageChanged || tagsChanged) {
|
||||
this.currentPage = newPage;
|
||||
this.activeFilters = newTags;
|
||||
this.images = [];
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
this.filterChange$.pipe(debounceTime(300), distinctUntilChanged()).subscribe((q) => {
|
||||
if (q) {
|
||||
this.tagService.list(q, 10).subscribe((r) => {
|
||||
|
||||
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