From 2d62cac5f7126f19442f1711946f0fad18512224 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 26 Feb 2025 19:04:35 -0500 Subject: [PATCH] Drag between products fix --- .../steps/ImageUploadStep/ImageUploadStep.tsx | 619 ++++++++++++------ 1 file changed, 434 insertions(+), 185 deletions(-) diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx index 4e3578a..6a4fd2e 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx @@ -27,7 +27,8 @@ import { useDndMonitor, DragMoveEvent, closestCorners, - CollisionDetection + CollisionDetection, + useDroppable } from '@dnd-kit/core'; import { arrayMove, @@ -68,26 +69,6 @@ type ProductImageSortable = ProductImage & { id: string; }; -// Custom collision detection algorithm that combines multiple strategies -const customCollisionDetection: CollisionDetection = (args) => { - // First, try pointer intersection - const pointerCollisions = pointerWithin(args); - - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - // If no pointer collisions, try rect intersection - const rectCollisions = rectIntersection(args); - - if (rectCollisions.length > 0) { - return rectCollisions; - } - - // If still no collisions, use closest corners - return closestCorners(args); -}; - export const ImageUploadStep = ({ data, file, @@ -121,6 +102,77 @@ export const ImageUploadStep = ({ // Track which product container is being hovered over const [activeDroppableId, setActiveDroppableId] = useState(null); + // Custom collision detection algorithm that prioritizes product containers + const customCollisionDetection: CollisionDetection = (args) => { + const { droppableContainers, active, pointerCoordinates } = args; + + if (!pointerCoordinates) { + return []; + } + + // Get the active container (product index) + const activeContainer = findContainer(active.id.toString()); + + // Get all collisions + const pointerCollisions = pointerWithin(args); + const rectCollisions = rectIntersection(args); + + // Combine collision methods for more reliable detection + const allCollisions = [...pointerCollisions, ...rectCollisions]; + + // Check for image collisions first - for reordering within the same container + const imageCollisions = allCollisions.filter(collision => { + const collisionId = collision.id.toString(); + // Only detect other images, not the active image + if (collisionId === active.id.toString()) return false; + + // Check if it's an image by looking for it in productImages + return productImages.some(img => img.id === collisionId); + }); + + // If we have image collisions within the same container, prioritize those for reordering + if (activeContainer && imageCollisions.length > 0) { + const sameContainerCollisions = imageCollisions.filter(collision => { + const image = productImages.find(img => img.id === collision.id); + return image && image.productIndex.toString() === activeContainer; + }); + + if (sameContainerCollisions.length > 0) { + return [sameContainerCollisions[0]]; // Return the first image collision in the same container + } + } + + // If no image collisions in the same container, check for product container collisions + const productContainerCollisions = allCollisions.filter( + collision => typeof collision.id === 'string' && collision.id.toString().startsWith('product-') + ); + + // If the active container is different from container collisions, prioritize those + if (activeContainer && productContainerCollisions.length > 0) { + const differentContainerCollisions = productContainerCollisions.filter(collision => { + const containerIndex = collision.id.toString().split('-')[1]; + return containerIndex !== activeContainer; + }); + + if (differentContainerCollisions.length > 0) { + return [differentContainerCollisions[0]]; + } + } + + // If we have any product container collisions, use those + if (productContainerCollisions.length > 0) { + return [productContainerCollisions[0]]; + } + + // Finally, check images in different containers + if (imageCollisions.length > 0) { + return [imageCollisions[0]]; + } + + // Fall back to all collisions + return allCollisions.length > 0 ? [allCollisions[0]] : []; + }; + // Handle drag start to set active image and prevent default behavior const handleDragStart = (event: DragStartEvent) => { const { active } = event; @@ -134,21 +186,30 @@ export const ImageUploadStep = ({ // Handle drag over to track which product container is being hovered const handleDragOver = (event: DragMoveEvent) => { - const { over } = event; + const { active, over } = event; if (!over) { setActiveDroppableId(null); return; } - // Check if we're over a product container - if (typeof over.id === 'string' && over.id.startsWith('product-')) { - setActiveDroppableId(over.id); - } else { - // We might be over another image, so get its product container + const activeContainer = findContainer(active.id.toString()); + let overContainer = null; + + // Check if we're over a product container directly + if (typeof over.id === 'string' && over.id.toString().startsWith('product-')) { + overContainer = over.id.toString(); + setActiveDroppableId(overContainer); + + // Log the hover state for debugging + console.log('Hovering over product container:', overContainer); + } + // Otherwise check if we're over another image + else { const overImage = productImages.find(img => img.id === over.id); if (overImage) { - setActiveDroppableId(`product-${overImage.productIndex}`); + overContainer = `product-${overImage.productIndex}`; + setActiveDroppableId(overContainer); } else { setActiveDroppableId(null); } @@ -171,12 +232,94 @@ export const ImageUploadStep = ({ }; }, [activeId]); - // Function to find the container (productIndex) an image belongs to + // Function to find container (productIndex) an image belongs to const findContainer = (id: string) => { const image = productImages.find(img => img.id === id); return image ? image.productIndex.toString() : null; }; + // Function to get images for a specific product + const getProductImages = (productIndex: number) => { + return productImages.filter(img => img.productIndex === productIndex); + }; + + // Add product IDs to the valid droppable elements + useEffect(() => { + // Add data-droppable attributes to make product containers easier to identify + data.forEach((_, index) => { + const container = document.getElementById(`product-${index}`); + if (container) { + container.setAttribute('data-droppable', 'true'); + container.setAttribute('aria-dropeffect', 'move'); + + // Check if the container has images + const hasImages = getProductImages(index).length > 0; + + // Add stronger attributes for empty containers to ensure they stand out as drop targets + if (!hasImages) { + container.setAttribute('data-empty', 'true'); + // Setting tabindex makes the element focusable which can help with accessibility + container.setAttribute('tabindex', '0'); + + // Add ARIA attributes to improve accessibility and recognition + container.setAttribute('aria-label', `Empty drop zone for product ${index + 1}`); + + // Ensure the empty container has sufficient size to be a drop target + if (container.offsetHeight < 100) { + container.style.minHeight = '100px'; + } + } else { + container.setAttribute('data-empty', 'false'); + } + } + }); + }, [data, productImages]); // Add productImages as a dependency to re-run when images change + + // Effect to register browser-level drag events on product containers + useEffect(() => { + // For each product container + data.forEach((_, index) => { + const container = document.getElementById(`product-${index}`); + + if (container) { + // Define handlers for native browser drag events + const handleNativeDragOver = (e: DragEvent) => { + e.preventDefault(); + if (getProductImages(index).length === 0) { + // Highlight empty containers during dragover + container.classList.add('border-primary', 'bg-primary/5'); + setActiveDroppableId(`product-${index}`); + } + }; + + const handleNativeDragLeave = (e: DragEvent) => { + if (getProductImages(index).length === 0) { + // Remove highlight when drag leaves + container.classList.remove('border-primary', 'bg-primary/5'); + if (activeDroppableId === `product-${index}`) { + setActiveDroppableId(null); + } + } + }; + + // Add these handlers + container.addEventListener('dragover', handleNativeDragOver); + container.addEventListener('dragleave', handleNativeDragLeave); + + // Log this for debugging + console.log(`Added native drag handlers to product-${index}`, { + isEmpty: getProductImages(index).length === 0 + }); + + // Return cleanup function + return () => { + container.removeEventListener('dragover', handleNativeDragOver); + container.removeEventListener('dragleave', handleNativeDragLeave); + }; + } + }); + }, [data, productImages]); // Re-run when data or productImages change + // Function to remove an image URL from a product const removeImageFromProduct = (productIndex: number, imageUrl: string) => { // Create a copy of the data @@ -221,19 +364,37 @@ export const ImageUploadStep = ({ const activeContainer = findContainer(activeId.toString()); let overContainer = null; - // Determine the target container: - // 1. First check if the over.id is a product container ID directly - if (typeof overId === 'string' && overId.startsWith('product-')) { - overContainer = overId.split('-')[1]; + // Check if overId is a product container directly + if (typeof overId === 'string' && overId.toString().startsWith('product-')) { + overContainer = overId.toString().split('-')[1]; + console.log('Dropping directly onto product container:', overContainer); } - // 2. Otherwise, it might be another image, so find its container + // Otherwise check if it's an image, so find its container else { overContainer = findContainer(overId.toString()); + console.log('Dropping onto another image in container:', overContainer); } - // If we couldn't determine either container, do nothing - if (!activeContainer || !overContainer) { - console.log('Could not determine containers', { activeContainer, overContainer, activeId, overId }); + // Log what was detected for debugging + console.log('Drag end detected:', { + activeId, + overId, + overContainer, + activeContainer, + isOverProduct: typeof overId === 'string' && overId.toString().startsWith('product-') + }); + + // If we couldn't determine active container, do nothing + if (!activeContainer) { + console.log('Could not determine source container', { activeContainer, activeId }); + setActiveId(null); + setActiveImage(null); + return; + } + + // If we couldn't determine the over container, do nothing + if (!overContainer) { + console.log('Could not determine target container', { overContainer, overId }); setActiveId(null); setActiveImage(null); return; @@ -251,14 +412,88 @@ export const ImageUploadStep = ({ return; } - // If source and target are the same product, handle reordering - if (sourceProductIndex === targetProductIndex) { - // Only if overId is an image id (not a product container) - otherwise we'd just drop at the end - if (!overId.toString().startsWith('product-')) { + // IMPORTANT: If source and target are different products, ALWAYS prioritize moving over reordering + if (sourceProductIndex !== targetProductIndex) { + console.log('Moving image between products', { sourceProductIndex, targetProductIndex }); + + // Create a copy of the image with the new product index + const newImage: ProductImageSortable = { + ...activeImage, + productIndex: targetProductIndex, + // Generate a new ID for the image in its new location + id: `image-${targetProductIndex}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` + }; + + // Remove the image from the source product and add to target product + setProductImages(items => { + // Remove the image from its current product + const filteredItems = items.filter(item => item.id !== activeId); + + // Add the image to the target product + filteredItems.push(newImage); + + // Check if the operation was successful + console.log('Image movement operation', { + originalCount: items.length, + newCount: filteredItems.length, + sourceImages: items.filter(item => item.productIndex === sourceProductIndex).length, + targetImagesBefore: items.filter(item => item.productIndex === targetProductIndex).length, + targetImagesAfter: filteredItems.filter(item => item.productIndex === targetProductIndex).length + }); + + // Update both products' image_url fields - creating new objects to ensure state updates + let updatedData = [...data]; // Start with a fresh copy + + // First remove from source + updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl); + + // Then add to target + updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl); + + // Show notification + toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`); + + return filteredItems; + }); + } + // Source and target are the same product - this is a reordering operation + else { + // Only attempt reordering if we have at least 2 images in this container + const productImages = getProductImages(sourceProductIndex); + + if (productImages.length >= 2) { + console.log('Reordering images within product', { sourceProductIndex, imagesCount: productImages.length }); + + // Handle reordering regardless of whether we're over a container or another image setProductImages(items => { // Filter to get only the images for this product const productFilteredItems = items.filter(item => item.productIndex === sourceProductIndex); + // If dropping onto the container itself, put at the end + if (overId.toString().startsWith('product-')) { + console.log('Dropping onto container - moving to end'); + // Find active index + const activeIndex = productFilteredItems.findIndex(item => item.id === activeId); + + if (activeIndex === -1) { + return items; // No change needed + } + + // Move active item to end (remove and push to end) + const newFilteredItems = [...productFilteredItems]; + const [movedItem] = newFilteredItems.splice(activeIndex, 1); + newFilteredItems.push(movedItem); + + // Create a new full list replacing the items for this product with the reordered ones + const newItems = items.filter(item => item.productIndex !== sourceProductIndex); + newItems.push(...newFilteredItems); + + // Update the product data with the new image order + const updatedData = updateProductImageOrder(sourceProductIndex, newFilteredItems); + + return newItems; + } + // Find indices within the filtered list const activeIndex = productFilteredItems.findIndex(item => item.id === activeId); const overIndex = productFilteredItems.findIndex(item => item.id === overId); @@ -281,39 +516,6 @@ export const ImageUploadStep = ({ return newItems; }); } - } - // If source and target are different products, handle reassigning - else { - // Create a copy of the image with the new product index - const newImage: ProductImageSortable = { - ...activeImage, - productIndex: targetProductIndex, - // Generate a new ID for the image in its new location - id: `image-${targetProductIndex}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` - }; - - // Remove the image from the source product and add to target product - setProductImages(items => { - // Remove the image from its current product - const filteredItems = items.filter(item => item.id !== activeId); - - // Add the image to the target product - filteredItems.push(newImage); - - // Update both products' image_url fields - creating new objects to ensure state updates - let updatedData = [...data]; // Start with a fresh copy - - // First remove from source - updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl); - - // Then add to target - updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl); - - // Show notification - toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`); - - return filteredItems; - }); } setActiveId(null); @@ -375,7 +577,7 @@ export const ImageUploadStep = ({ return newData; }; - + // Function to handle image upload const handleImageUpload = async (files: FileList | File[], productIndex: number) => { if (!files || files.length === 0) return; @@ -621,18 +823,72 @@ export const ImageUploadStep = ({ }; }, []); - // Add product IDs to the valid droppable elements - useEffect(() => { - // Add data-droppable attributes to make product containers easier to identify - data.forEach((_, index) => { - const container = document.getElementById(`product-${index}`); - if (container) { - container.setAttribute('data-droppable', 'true'); - container.setAttribute('aria-dropeffect', 'move'); + // Function to remove an image + const removeImage = async (imageIndex: number) => { + const image = productImages[imageIndex]; + if (!image) return; + + try { + // Extract the filename from the URL + const urlParts = image.imageUrl.split('/'); + const filename = urlParts[urlParts.length - 1]; + + // Call API to delete the image + const response = await fetch(`${config.apiUrl}/import/delete-image`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + imageUrl: image.imageUrl, + filename + }), + }); + + if (!response.ok) { + throw new Error('Failed to delete image'); } - }); - }, [data]); + + // Remove the image from our state + setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex)); + + // Remove the image URL from the product data + const updatedData = removeImageFromProduct(image.productIndex, image.imageUrl); + + toast.success('Image removed successfully'); + } catch (error) { + console.error('Delete error:', error); + toast.error(`Failed to remove image: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + // Handle calling onSubmit with the current data + const handleSubmit = useCallback(async () => { + setIsSubmitting(true); + try { + await onSubmit(data, file); + } catch (error) { + console.error('Submit error:', error); + toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsSubmitting(false); + } + }, [data, file, onSubmit]); + + // Function to ensure URLs are properly formatted with absolute paths + const getFullImageUrl = (url: string): string => { + // If the URL is already absolute (starts with http:// or https://) return it as is + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + // Otherwise, it's a relative URL, prepend the domain + const baseUrl = 'https://inventory.acot.site'; + // Make sure url starts with / for path + const path = url.startsWith('/') ? url : `/${url}`; + return `${baseUrl}${path}`; + }; + // Generic dropzone component const GenericDropzone = () => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ @@ -718,77 +974,6 @@ export const ImageUploadStep = ({ ); }; - - // Function to remove an image - const removeImage = async (imageIndex: number) => { - const image = productImages[imageIndex]; - if (!image) return; - - try { - // Extract the filename from the URL - const urlParts = image.imageUrl.split('/'); - const filename = urlParts[urlParts.length - 1]; - - // Call API to delete the image - const response = await fetch(`${config.apiUrl}/import/delete-image`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - imageUrl: image.imageUrl, - filename - }), - }); - - if (!response.ok) { - throw new Error('Failed to delete image'); - } - - // Remove the image from our state - setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex)); - - // Remove the image URL from the product data - const updatedData = removeImageFromProduct(image.productIndex, image.imageUrl); - - toast.success('Image removed successfully'); - } catch (error) { - console.error('Delete error:', error); - toast.error(`Failed to remove image: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - }; - - // Handle calling onSubmit with the current data - const handleSubmit = useCallback(async () => { - setIsSubmitting(true); - try { - await onSubmit(data, file); - } catch (error) { - console.error('Submit error:', error); - toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`); - } finally { - setIsSubmitting(false); - } - }, [data, file, onSubmit]); - - // Get images for a specific product - const getProductImages = (productIndex: number) => { - return productImages.filter(img => img.productIndex === productIndex); - }; - - // Function to ensure URLs are properly formatted with absolute paths - const getFullImageUrl = (url: string): string => { - // If the URL is already absolute (starts with http:// or https://) return it as is - if (url.startsWith('http://') || url.startsWith('https://')) { - return url; - } - - // Otherwise, it's a relative URL, prepend the domain - const baseUrl = 'https://inventory.acot.site'; - // Make sure url starts with / for path - const path = url.startsWith('/') ? url : `/${url}`; - return `${baseUrl}${path}`; - }; // Component for individual unassigned image item const UnassignedImageItem = ({ image, index }: { image: UnassignedImage; index: number }) => { @@ -980,12 +1165,18 @@ export const ImageUploadStep = ({ } }); + // Create a new style object with fixed dimensions to prevent distortion const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 10 : 0, // Higher z-index when dragging touchAction: 'none' as 'none', // Prevent touch scrolling during drag + width: '96px', + height: '96px', + flexShrink: 0, + flexGrow: 0, + position: 'relative' as 'relative', }; // Create a ref for the buttons to exclude them from drag listeners @@ -996,7 +1187,7 @@ export const ImageUploadStep = ({
@@ -1015,7 +1206,7 @@ export const ImageUploadStep = ({ /> {/* Fix zoom button with proper state management */} @@ -1039,7 +1230,7 @@ export const ImageUploadStep = ({ @@ -1091,15 +1282,43 @@ export const ImageUploadStep = ({ const getProductContainerClasses = (index: number) => { const isValidDropTarget = activeId && findContainer(activeId) !== index.toString(); const isActiveDropTarget = activeDroppableId === `product-${index}`; + const hasImages = getProductImages(index).length > 0; return cn( - "flex flex-wrap gap-2 overflow-x-auto flex-1 min-h-[6rem] rounded-md p-2 transition-all", - isValidDropTarget && "border border-dashed", + "flex-1 min-h-[6rem] rounded-md p-2 transition-all", + // Always add a border for empty containers to make them visible as drop targets + !hasImages && "border-2 border-dashed border-secondary-foreground/30", + // Active drop target styling isValidDropTarget && isActiveDropTarget - ? "border-primary bg-primary/10 border-2" - : isValidDropTarget - ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-primary/5" - : "" + ? "border-2 border-dashed border-primary bg-primary/10" + : isValidDropTarget && !hasImages + ? "border-2 border-dashed border-muted-foreground/40 bg-muted/20" + : isValidDropTarget + ? "border border-dashed border-muted-foreground/30" + : "" + ); + }; + + // Add a DroppableContainer component for empty product containers + const DroppableContainer = ({ id, children, isEmpty }: { id: string; children: React.ReactNode; isEmpty: boolean }) => { + const { setNodeRef } = useDroppable({ + id, + data: { + type: 'container', + isEmpty + } + }); + + return ( +
+ {children} +
); }; @@ -1159,7 +1378,7 @@ export const ImageUploadStep = ({ "ring-2 ring-primary bg-primary/5" )} > - +

{product.name || `Product #${index + 1}`}

@@ -1168,7 +1387,7 @@ export const ImageUploadStep = ({ Supplier #: {product.supplier_no || 'N/A'}
- +
{/* Dropzone for image upload always on the left */} @@ -1176,22 +1395,44 @@ export const ImageUploadStep = ({ {/* Images appear to the right of the dropzone in a sortable container */}
{ + // This is a native event handler to ensure the browser recognizes the drop zone + e.preventDefault(); + e.stopPropagation(); + if (getProductImages(index).length === 0) { + setActiveDroppableId(`product-${index}`); + } + }} > - img.id)} - strategy={horizontalListSortingStrategy} + - {getProductImages(index).map((image, imgIndex) => ( - - ))} - + {getProductImages(index).length > 0 ? ( + img.id)} + strategy={horizontalListSortingStrategy} + > + {getProductImages(index).map((image, imgIndex) => ( + + ))} + + ) : ( +
+ Drop images here +
+ )} +
{/* Hidden file input for backwards compatibility */} @@ -1211,9 +1452,17 @@ export const ImageUploadStep = ({
{/* Drag overlay for showing the dragged image */} - + {activeId && activeImage && ( -
+