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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user