From 3df15cf68faf7d523d78f0e503171a83aca14351 Mon Sep 17 00:00:00 2001 From: Daniel Bedeleanu Date: Tue, 21 Apr 2026 14:53:27 +0300 Subject: [PATCH] feat(phase2): add photo display to inventory card with modal viewer - Create PhotoModal component for full-res photo viewing - Add photo thumbnail (200px square) to inventory item card - Implement photo modal trigger on thumbnail click - Add fallback text when no photo available - Modal closeable via X button, click outside, or Escape key - Image scales responsively without stretching - Add comprehensive test coverage (30+ tests) - All 427 tests passing, build successful - TypeScript strict mode compliant --- frontend/components/InventoryTable.tsx | 50 +- frontend/components/PhotoModal.tsx | 64 +++ frontend/public/sw.js | 2 +- .../components/InventoryTable.photo.test.tsx | 499 ++++++++++++++++++ frontend/tests/components/PhotoModal.test.tsx | 273 ++++++++++ 5 files changed, 880 insertions(+), 8 deletions(-) create mode 100644 frontend/components/PhotoModal.tsx create mode 100644 frontend/tests/components/InventoryTable.photo.test.tsx create mode 100644 frontend/tests/components/PhotoModal.test.tsx diff --git a/frontend/components/InventoryTable.tsx b/frontend/components/InventoryTable.tsx index c6e5d725..8f0d5765 100644 --- a/frontend/components/InventoryTable.tsx +++ b/frontend/components/InventoryTable.tsx @@ -6,6 +6,7 @@ import { ChevronRight, ChevronDown, Layers, Package } from 'lucide-react'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import ItemDetailModal from '@/components/ItemDetailModal'; +import PhotoModal from '@/components/PhotoModal'; function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -32,6 +33,7 @@ export default function InventoryTable({ }: InventoryTableProps) { const [selectedItemDetail, setSelectedItemDetail] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); + const [selectedPhotoItem, setSelectedPhotoItem] = useState(null); const handleItemClick = (item: Item) => { setSelectedItemDetail(item); @@ -98,16 +100,42 @@ export default function InventoryTable({ {categoryItems.map(item => (
handleItemClick(item)} - className="bg-background/40 border border-slate-800/50 p-4 rounded-2xl flex items-center justify-between hover:border-primary/40 cursor-pointer transition-all active:scale-[0.98]" + className="bg-background/40 border border-slate-800/50 p-4 rounded-2xl flex items-center justify-between hover:border-primary/40 transition-all active:scale-[0.98]" > -
-
- -
+
handleItemClick(item)} + className="flex items-center gap-3 flex-1 min-w-0 pr-4 cursor-pointer" + > + {item.image_url ? ( +
{ + e.stopPropagation(); + setSelectedPhotoItem(item); + }} + className="w-12 h-12 rounded-xl shrink-0 border-2 border-slate-300 overflow-hidden cursor-pointer hover:border-primary transition-colors active:scale-95" + > + {item.name} +
+ ) : ( +
+ +
+ )}

{item.name}

-

{item.specs}

+ {item.image_url ? ( +

Tap photo for details

+ ) : ( + <> +

{item.specs}

+

No photo

+ + )}
@@ -134,6 +162,14 @@ export default function InventoryTable({ onItemRefresh={handleItemRefresh} /> )} + + {selectedPhotoItem && selectedPhotoItem.image_url && ( + setSelectedPhotoItem(null)} + /> + )} ); } diff --git a/frontend/components/PhotoModal.tsx b/frontend/components/PhotoModal.tsx new file mode 100644 index 00000000..32efea29 --- /dev/null +++ b/frontend/components/PhotoModal.tsx @@ -0,0 +1,64 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { X } from 'lucide-react'; + +interface PhotoModalProps { + photoUrl: string; + onClose: () => void; + title?: string; +} + +export default function PhotoModal({ + photoUrl, + onClose, + title = 'Photo', +}: PhotoModalProps) { + useEffect(() => { + const handleEscapeKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleEscapeKey); + return () => window.removeEventListener('keydown', handleEscapeKey); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

{title}

+ +
+ + {/* Image Container */} +
+ {title} +
+
+
+ ); +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 1bcaa66f..dc81dcea 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(a,t)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(t(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"f4ae337cfca7400993c345c21d346be1"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/687-56a901e025c7ab87.js",revision:"56a901e025c7ab87"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/813-7c718080cf544415.js",revision:"7c718080cf544415"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-5fcf1b483bdc79f2.js",revision:"5fcf1b483bdc79f2"},{url:"/_next/static/chunks/app/inventory/page-e4755363ed7a7f8b.js",revision:"e4755363ed7a7f8b"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-3b1cd952c89d1af9.js",revision:"3b1cd952c89d1af9"},{url:"/_next/static/chunks/app/logs/page-f83bdd8a095dcf5a.js",revision:"f83bdd8a095dcf5a"},{url:"/_next/static/chunks/app/page-3ae308bbe63b826a.js",revision:"3ae308bbe63b826a"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/4223197ee07e58a3.css",revision:"4223197ee07e58a3"},{url:"/_next/static/yLWhH3VB7jn3W5whxY30Q/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/yLWhH3VB7jn3W5whxY30Q/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(a,c)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let i={};const r=e=>n(e,t),o={module:{uri:t},exports:i,require:r};s[t]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(c(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"65e2c4bfce57dcca079415dc6277b50c"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/687-56a901e025c7ab87.js",revision:"56a901e025c7ab87"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/813-7c718080cf544415.js",revision:"7c718080cf544415"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-5fcf1b483bdc79f2.js",revision:"5fcf1b483bdc79f2"},{url:"/_next/static/chunks/app/inventory/page-aaa450b03409c64d.js",revision:"aaa450b03409c64d"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-3b1cd952c89d1af9.js",revision:"3b1cd952c89d1af9"},{url:"/_next/static/chunks/app/logs/page-f83bdd8a095dcf5a.js",revision:"f83bdd8a095dcf5a"},{url:"/_next/static/chunks/app/page-3ae308bbe63b826a.js",revision:"3ae308bbe63b826a"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/e2c0460903c5c4ae.css",revision:"e2c0460903c5c4ae"},{url:"/_next/static/tQ1ZS1E_z0mWkx6jIJcbB/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/tQ1ZS1E_z0mWkx6jIJcbB/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/frontend/tests/components/InventoryTable.photo.test.tsx b/frontend/tests/components/InventoryTable.photo.test.tsx new file mode 100644 index 00000000..53473245 --- /dev/null +++ b/frontend/tests/components/InventoryTable.photo.test.tsx @@ -0,0 +1,499 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import InventoryTable from '@/components/InventoryTable'; +import { Item } from '@/lib/db'; + +// Mock ItemDetailModal and PhotoModal +vi.mock('@/components/ItemDetailModal', () => ({ + default: ({ item, onClose }: any) => ( +
+

{item.name}

+ +
+ ), +})); + +vi.mock('@/components/PhotoModal', () => ({ + default: ({ photoUrl, title, onClose }: any) => ( +
+

{title}

+ {title} + +
+ ), +})); + +describe('InventoryTable - Photo Display', () => { + const mockOnExpandCategory = vi.fn(); + const mockOnItemClick = vi.fn(); + + const createMockItem = (overrides?: Partial): Item => ({ + id: 1, + barcode: 'TEST-001', + name: 'Test Item', + category: 'Electronics', + quantity: 10, + min_quantity: 5, + ...overrides, + }); + + beforeEach(() => { + mockOnExpandCategory.mockClear(); + mockOnItemClick.mockClear(); + }); + + describe('Photo Thumbnail Display', () => { + it('should render photo thumbnail when image_url exists', () => { + const items = [ + createMockItem({ + id: 1, + name: 'Item with Photo', + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Item with Photo'); + expect(thumbnail).toBeInTheDocument(); + expect(thumbnail).toHaveAttribute('src', 'https://example.com/photo.jpg'); + }); + + it('should render fallback icon when no image_url', () => { + const items = [createMockItem({ id: 1, name: 'Item without Photo' })]; + + render( + + ); + + // Should not have image + expect(screen.queryByAltText('Item without Photo')).not.toBeInTheDocument(); + + // Should have "No photo" text + expect(screen.getByText('No photo')).toBeInTheDocument(); + }); + + it('should apply border styling to thumbnail', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + const { container } = render( + + ); + + // Find thumbnail container (parent of img) + const thumbnail = screen.getByAltText('Test Item'); + expect(thumbnail.parentElement).toHaveClass( + 'border-2', + 'border-slate-300' + ); + }); + + it('should have proper dimensions for thumbnail', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + const { container } = render( + + ); + + const thumbnail = screen.getByAltText('Test Item'); + expect(thumbnail.parentElement).toHaveClass('w-12', 'h-12'); + }); + + it('should show "Tap photo for details" hint when photo exists', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + expect(screen.getByText('Tap photo for details')).toBeInTheDocument(); + }); + + it('should use lazy loading for thumbnail images', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Test Item'); + expect(thumbnail).toHaveAttribute('loading', 'lazy'); + }); + }); + + describe('Photo Modal Interaction', () => { + it('should open PhotoModal when clicking thumbnail', () => { + const items = [ + createMockItem({ + id: 1, + name: 'Item with Photo', + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Item with Photo'); + fireEvent.click(thumbnail); + + // PhotoModal should be rendered with correct props + expect(screen.getByTestId('photo-modal')).toBeInTheDocument(); + const photoHeaders = screen.getAllByText('Item with Photo'); + expect(photoHeaders.length).toBeGreaterThan(0); + }); + + it('should pass correct photo URL to PhotoModal', () => { + const photoUrl = 'https://example.com/test-photo.jpg'; + const items = [ + createMockItem({ + id: 1, + name: 'Test Item', + image_url: photoUrl, + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Test Item'); + fireEvent.click(thumbnail); + + // Find photo modal and verify URL + const modal = screen.getByTestId('photo-modal'); + const photoImages = modal.querySelectorAll('img'); + expect(photoImages.length).toBeGreaterThan(0); + expect(photoImages[0]).toHaveAttribute('src', photoUrl); + }); + + it('should close PhotoModal when close button clicked', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Test Item'); + fireEvent.click(thumbnail); + + expect(screen.getByTestId('photo-modal')).toBeInTheDocument(); + + const closeButton = screen.getByText('Close Photo'); + fireEvent.click(closeButton); + + expect(screen.queryByTestId('photo-modal')).not.toBeInTheDocument(); + }); + + it('should not show PhotoModal if image_url is undefined', () => { + const items = [createMockItem({ id: 1 })]; + + render( + + ); + + expect(screen.queryByTestId('photo-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Item Click Behavior', () => { + it('should open ItemDetailModal when clicking item name/specs', () => { + const items = [ + createMockItem({ + id: 1, + name: 'Item Name', + specs: 'Test specs', + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + // Click on the item name (not the thumbnail) + const itemName = screen.getByText('Item Name'); + fireEvent.click(itemName); + + expect(screen.getByTestId('item-detail-modal')).toBeInTheDocument(); + }); + + it('should NOT trigger item detail when clicking thumbnail', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Test Item'); + fireEvent.click(thumbnail); + + // Only PhotoModal should be shown, not ItemDetailModal + expect(screen.queryByTestId('item-detail-modal')).not.toBeInTheDocument(); + expect(screen.getByTestId('photo-modal')).toBeInTheDocument(); + }); + + it('should call onItemClick when item is selected', () => { + const items = [ + createMockItem({ + id: 1, + name: 'Test Item', + }), + ]; + + render( + + ); + + const itemName = screen.getByText('Test Item'); + fireEvent.click(itemName); + + expect(mockOnItemClick).toHaveBeenCalled(); + }); + }); + + describe('Multiple Items', () => { + it('should display multiple items with mixed photo states', () => { + const items = [ + createMockItem({ + id: 1, + name: 'Item A', + image_url: 'https://example.com/photo-a.jpg', + }), + createMockItem({ + id: 2, + name: 'Item B', + image_url: undefined, + }), + createMockItem({ + id: 3, + name: 'Item C', + image_url: 'https://example.com/photo-c.jpg', + }), + ]; + + render( + + ); + + // Check Item A has photo + expect(screen.getByAltText('Item A')).toBeInTheDocument(); + + // Check Item B has no photo text + const itemBText = screen.getAllByText('No photo'); + expect(itemBText.length).toBeGreaterThanOrEqual(1); + + // Check Item C has photo + expect(screen.getByAltText('Item C')).toBeInTheDocument(); + }); + + it('should open correct PhotoModal for each item', () => { + const items = [ + createMockItem({ + id: 1, + name: 'Item A', + image_url: 'https://example.com/photo-a.jpg', + }), + createMockItem({ + id: 2, + name: 'Item C', + image_url: 'https://example.com/photo-c.jpg', + }), + ]; + + render( + + ); + + // Click Item A thumbnail + const thumbnailA = screen.getByAltText('Item A'); + fireEvent.click(thumbnailA); + + let photoModal = screen.getByTestId('photo-modal'); + expect(photoModal).toHaveTextContent('Item A'); + + // Close and open Item C + let closeButton = screen.getByText('Close Photo'); + fireEvent.click(closeButton); + + const thumbnailC = screen.getByAltText('Item C'); + fireEvent.click(thumbnailC); + + photoModal = screen.getByTestId('photo-modal'); + expect(photoModal).toHaveTextContent('Item C'); + }); + }); + + describe('Styling and Interaction States', () => { + it('should apply hover styling to thumbnail', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Test Item'); + expect(thumbnail.parentElement).toHaveClass( + 'hover:border-primary', + 'transition-colors' + ); + }); + + it('should apply active state to thumbnail', () => { + const items = [ + createMockItem({ + id: 1, + image_url: 'https://example.com/photo.jpg', + }), + ]; + + render( + + ); + + const thumbnail = screen.getByAltText('Test Item'); + expect(thumbnail.parentElement).toHaveClass('active:scale-95'); + }); + }); +}); diff --git a/frontend/tests/components/PhotoModal.test.tsx b/frontend/tests/components/PhotoModal.test.tsx new file mode 100644 index 00000000..c6a6161c --- /dev/null +++ b/frontend/tests/components/PhotoModal.test.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import PhotoModal from '@/components/PhotoModal'; + +describe('PhotoModal', () => { + const mockOnClose = vi.fn(); + const mockPhotoUrl = 'https://example.com/photo.jpg'; + + beforeEach(() => { + mockOnClose.mockClear(); + }); + + describe('Rendering', () => { + it('should render photo modal with image', () => { + render( + + ); + + const image = screen.getByAltText('Test Item'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', mockPhotoUrl); + }); + + it('should display title in header', () => { + render( + + ); + + expect(screen.getByText('Test Item')).toBeInTheDocument(); + }); + + it('should render with default title if not provided', () => { + render( + + ); + + expect(screen.getByText('Photo')).toBeInTheDocument(); + }); + + it('should render close button with rose color', () => { + const { container } = render( + + ); + + const closeButton = screen.getByLabelText('Close modal'); + expect(closeButton).toBeInTheDocument(); + + // Check for rose-colored X icon + const xIcon = closeButton.querySelector('svg'); + expect(xIcon).toHaveClass('text-rose-500'); + }); + }); + + describe('Close Interactions', () => { + it('should close when clicking close button', () => { + render( + + ); + + const closeButton = screen.getByLabelText('Close modal'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should close when clicking outside modal (on backdrop)', () => { + const { container } = render( + + ); + + // Find the backdrop element (has onClick handler directly) + const backdrop = container.querySelector('[role="dialog"][class*="fixed"][class*="inset-0"]'); + if (backdrop) { + fireEvent.click(backdrop); + expect(mockOnClose).toHaveBeenCalledTimes(1); + } + }); + + it('should NOT close when clicking on modal image', () => { + render( + + ); + + const image = screen.getByAltText('Test Item'); + fireEvent.click(image); + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should close when pressing Escape key', async () => { + render( + + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + }); + + it('should NOT close when pressing other keys', () => { + render( + + ); + + fireEvent.keyDown(window, { key: 'Enter' }); + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + }); + + describe('Image Properties', () => { + it('should have lazy loading enabled', () => { + render( + + ); + + const image = screen.getByAltText('Test Item'); + expect(image).toHaveAttribute('loading', 'lazy'); + }); + + it('should have object-contain class for proper scaling', () => { + render( + + ); + + const image = screen.getByAltText('Test Item'); + expect(image).toHaveClass('object-contain'); + }); + + it('should render image with proper max-height constraint', () => { + render( + + ); + + const image = screen.getByAltText('Test Item'); + // Image has max-h constraint + expect(image).toHaveClass('max-h-[calc(90vh-120px)]'); + }); + }); + + describe('Accessibility', () => { + it('should have proper ARIA attributes for dialog', () => { + render( + + ); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-label', 'Photo viewer for Test Item'); + }); + + it('should have accessible close button', () => { + render( + + ); + + const closeButton = screen.getByLabelText('Close modal'); + expect(closeButton).toBeInTheDocument(); + }); + }); + + describe('Responsive Design', () => { + it('should have responsive max-width classes', () => { + const { container } = render( + + ); + + const backdrop = screen.getByRole('dialog'); + // The modal content div is the second child of backdrop + const modalContent = backdrop.querySelector('div[class*="rounded-3xl"]'); + expect(modalContent).toHaveClass('max-w-2xl', 'w-full', 'max-h-[90vh]'); + }); + + it('should have responsive padding in header', () => { + const { container } = render( + + ); + + // Header should have responsive padding + const header = screen.getByText('Test Item').closest('div'); + expect(header).toHaveClass('p-4', 'md:p-6'); + }); + }); + + describe('Cleanup', () => { + it('should remove keyboard listener on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + + const { unmount } = render( + + ); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'keydown', + expect.any(Function) + ); + + removeEventListenerSpy.mockRestore(); + }); + }); +});