diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx index fb1515ba2..c46985ef6 100644 --- a/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx +++ b/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx @@ -50,6 +50,22 @@ export const EmptyWithLabel: Story = { }, }; +export const WithImage: Story = { + args: { + currentUrl: + 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Crect%20width%3D%2248%22%20height%3D%2248%22%20rx%3D%228%22%20fill%3D%22%236366f1%22/%3E%3C/svg%3E', + ariaLabel: 'Upload logo', + }, + parameters: { + docs: { + description: { + story: + 'With an uploaded image. Hover or focus the control to reveal the replace overlay; a remove button sits in the corner.', + }, + }, + }, +}; + export const SmallSize: Story = { args: { size: 'sm', diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx new file mode 100644 index 000000000..0a5703588 --- /dev/null +++ b/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { checkAccessibility } from '@/tests/utils/a11y'; + +// Mock the save-image mutation so the upload path resolves without a backend. +// `mockSaveImage` is reassigned per-test (e.g. to a deferred promise) to drive +// the in-flight `isUploading` state. +let mockSaveImage = vi.fn().mockResolvedValue({ filename: 'logo.png' }); +vi.mock('../hooks/mutations', () => ({ + useSaveImage: () => ({ mutateAsync: (args: unknown) => mockSaveImage(args) }), +})); + +// The Next.js Image wrapper is only reached when an existing `currentUrl` is +// shown; render a plain so jsdom has nothing to resolve. +vi.mock('@/app/components/ui/data-display/image', () => ({ + Image: (props: Record) => ( + {props.alt + ), +})); + +import { ImageUploadField } from './image-upload-field'; + +const baseProps = { + organizationId: 'org_demo', + imageType: 'logo' as const, + ariaLabel: 'Upload logo', +}; + +function makeFile(name: string, type: string) { + return new File(['fake-image-bytes'], name, { type }); +} + +function dropFile(element: Element, file: File) { + fireEvent.drop(element, { dataTransfer: { files: [file] } }); +} + +beforeEach(() => { + mockSaveImage = vi.fn().mockResolvedValue({ filename: 'logo.png' }); + // jsdom does not implement object-URL APIs the component calls on upload. + global.URL.createObjectURL = vi.fn(() => 'blob:preview'); + global.URL.revokeObjectURL = vi.fn(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('ImageUploadField', () => { + it('renders an accessible upload control', () => { + render(); + expect( + screen.getByRole('button', { name: 'Upload logo' }), + ).toBeInTheDocument(); + }); + + // Happy path: a valid image dropped onto the control runs the upload and + // reports the saved filename back to the parent. + it('uploads a valid image dropped onto the control', async () => { + const onUpload = vi.fn(); + render(); + + dropFile( + screen.getByRole('button', { name: 'Upload logo' }), + makeFile('logo.png', 'image/png'), + ); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(mockSaveImage).toHaveBeenCalledTimes(1); + expect(mockSaveImage).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org_demo', + type: 'logo', + mimeType: 'image/png', + }), + ); + expect(onUpload).toHaveBeenCalledWith('logo.png', expect.any(File)); + }); + + // Edge: a second drop while an upload is in flight must be ignored so the + // same control cannot fire two concurrent uploads. + it('ignores a drop while an upload is already in flight', async () => { + // Keep the first upload pending so `isUploading` stays true for the second. + let resolveUpload: (value: { filename: string }) => void = () => {}; + mockSaveImage = vi.fn().mockImplementation( + () => + new Promise<{ filename: string }>((resolve) => { + resolveUpload = resolve; + }), + ); + + render(); + const button = screen.getByRole('button', { name: 'Upload logo' }); + + dropFile(button, makeFile('logo.png', 'image/png')); + // The control disables itself once the upload starts. + await waitFor(() => expect(button).toBeDisabled()); + + dropFile(button, makeFile('logo-2.png', 'image/png')); + + expect(mockSaveImage).toHaveBeenCalledTimes(1); + resolveUpload({ filename: 'logo.png' }); + }); + + // Edge: a valid file whose browser-reported MIME is blank/non-`image/*` is + // still accepted via its extension (e.g. a `.ico` reporting empty type), + // keeping the drop path in sync with the picker's `accept` list. + it('accepts a dropped file with a non-image MIME but an accepted extension', async () => { + const onUpload = vi.fn(); + render(); + + dropFile( + screen.getByRole('button', { name: 'Upload logo' }), + makeFile('favicon.ico', ''), + ); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(mockSaveImage).toHaveBeenCalledTimes(1); + }); + + // Error: a non-image file is rejected before any upload is attempted. + it('ignores a dropped non-image file', () => { + const onUpload = vi.fn(); + render(); + + dropFile( + screen.getByRole('button', { name: 'Upload logo' }), + makeFile('notes.txt', 'text/plain'), + ); + + expect(mockSaveImage).not.toHaveBeenCalled(); + expect(onUpload).not.toHaveBeenCalled(); + }); + + describe('accessibility', () => { + it('passes axe audit', async () => { + const { container } = render( + , + ); + await checkAccessibility(container); + }); + }); +}); diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.tsx index 24d1b09e9..4d2d11a71 100644 --- a/services/platform/app/features/settings/branding/components/image-upload-field.tsx +++ b/services/platform/app/features/settings/branding/components/image-upload-field.tsx @@ -3,7 +3,7 @@ import { VStack } from '@tale/ui/layout'; import { Spinner } from '@tale/ui/spinner'; import { Text } from '@tale/ui/text'; -import { Plus, X } from 'lucide-react'; +import { Plus, Upload, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Image } from '@/app/components/ui/data-display/image'; @@ -12,6 +12,17 @@ import { cn } from '@/lib/utils/cn'; import { useSaveImage } from '../hooks/mutations'; const ACCEPTED_IMAGE_TYPES = '.png,.svg,.jpg,.jpeg,.webp,.ico'; +const ACCEPTED_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.webp', '.ico']; + +// Mirror the `` list for the drag-and-drop path. Some valid +// uploads report an empty or non-`image/*` MIME type (e.g. a `.ico` whose +// browser-reported type is blank), so fall back to the file extension rather +// than rejecting them — keeping picker and drop acceptance in sync. +function isAcceptedImage(file: File): boolean { + if (file.type.startsWith('image/')) return true; + const name = file.name.toLowerCase(); + return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext)); +} interface ImageUploadFieldProps { organizationId: string; @@ -37,6 +48,7 @@ export function ImageUploadField({ ariaLabel, }: ImageUploadFieldProps) { const [isUploading, setIsUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); const [isRemoved, setIsRemoved] = useState(false); const fileInputRef = useRef(null); @@ -65,11 +77,8 @@ export function ImageUploadField({ fileInputRef.current?.click(); }, []); - const handleFileChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const uploadFile = useCallback( + async (file: File) => { if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); } @@ -79,6 +88,7 @@ export function ImageUploadField({ setPreviewUrl(objectUrl); onPreviewUrlChange?.(objectUrl); setIsRemoved(false); + setIsDragging(false); setIsUploading(true); try { @@ -113,6 +123,38 @@ export function ImageUploadField({ [organizationId, saveImage, imageType, onUpload, onPreviewUrlChange], ); + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) void uploadFile(file); + }, + [uploadFile], + ); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (!isUploading) setIsDragging(true); + }, + [isUploading], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (isUploading) return; + const file = e.dataTransfer.files?.[0]; + if (file && isAcceptedImage(file)) void uploadFile(file); + }, + [isUploading, uploadFile], + ); + const handleRemove = useCallback(() => { setPreviewUrl(null); onPreviewUrlChange?.(null); @@ -132,10 +174,17 @@ export function ImageUploadField({ {displayUrl && !isUploading && onRemove && (