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:
@@ -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,51 @@ 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();
|
||||
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 {
|
||||
|
||||
@@ -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><a class="home-link" (click)="router.navigate(['/'])">Reactbin</a></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>
|
||||
|
||||
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