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 1f1265d..8b9f261 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 @@ -37,18 +37,22 @@ import { DialogContent, DialogTrigger, } from "@/components/ui/dialog"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { createPortal } from "react-dom"; -type Props = { - data: any[]; +type Product = { + id?: string | number; + name?: string; + supplier_no?: string; + upc?: string; + sku?: string; + model?: string; + product_images?: string | string[]; +}; + +type Props = { + data: Product[]; file: File; onBack?: () => void; - onSubmit: (data: any[], file: File) => void | Promise; + onSubmit: (data: Product[], file: File) => void | Promise; } type ProductImage = { @@ -56,6 +60,12 @@ type ProductImage = { imageUrl: string; loading: boolean; fileName: string; + pid?: string | number; + iid?: string; + width?: number; + height?: number; + type?: number; + order?: number; } type UnassignedImage = { @@ -68,13 +78,13 @@ type ProductImageSortable = ProductImage & { id: string; }; -export const ImageUploadStep = ({ +export const ImageUploadStep = ({ data, file, onBack, onSubmit -}: Props) => { - const { translations } = useRsi(); +}: Props) => { + useRsi(); const [isSubmitting, setIsSubmitting] = useState(false); const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}); const [unassignedImages, setUnassignedImages] = useState([]); @@ -95,7 +105,7 @@ export const ImageUploadStep = ({ // Convert existing product_images to ProductImageSortable objects const initialImages: ProductImageSortable[] = []; - data.forEach((product, productIndex) => { + data.forEach((product: Product, productIndex: number) => { if (product.product_images) { let imageUrls: string[] = []; @@ -119,7 +129,11 @@ export const ImageUploadStep = ({ productIndex, imageUrl: url.trim(), loading: false, - fileName: `Image ${i + 1}` + fileName: `Image ${i + 1}`, + pid: product.id || '', + iid: '', + width: 0, + height: 0 }); } }); @@ -232,7 +246,7 @@ 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) => { + data.forEach((_: Product, index: number) => { const container = document.getElementById(`product-${index}`); if (container) { container.setAttribute('data-droppable', 'true'); @@ -255,7 +269,7 @@ export const ImageUploadStep = ({ // Effect to register browser-level drag events on product containers useEffect(() => { // For each product container - data.forEach((_, index) => { + data.forEach((_: Product, index: number) => { const container = document.getElementById(`product-${index}`); if (container) { @@ -343,88 +357,6 @@ export const ImageUploadStep = ({ }; // Function to add an image URL to a product - const addImageToProduct = (productIndex: number, imageUrl: string, imageData?: any) => { - // Create a copy of the data - const newData = [...data]; - - // Get the current product - const product = newData[productIndex]; - - // Initialize product_images array if it doesn't exist - if (!product.product_images) { - product.product_images = []; - } - - // Handle different formats of product_images - let images: any[] = []; - - if (typeof product.product_images === 'string') { - try { - // Try to parse as JSON - images = JSON.parse(product.product_images); - } catch (e) { - // If not JSON, split by comma if it's a string - images = product.product_images.split(',').filter(Boolean).map(url => ({ - imageUrl: url.trim(), - pid: product.id || 0, - iid: 0, - type: 0, - order: 255, - width: 0, - height: 0, - hidden: 0 - })); - } - } else if (Array.isArray(product.product_images)) { - // Use the array directly - images = product.product_images; - } else if (product.product_images) { - // Handle case where it might be a single value - images = [product.product_images]; - } - - // Check if the image URL already exists - const exists = images.some(img => { - const imgUrl = typeof img === 'string' ? img : img.imageUrl; - return imgUrl === imageUrl; - }); - - // Only add if the URL doesn't already exist - if (!exists) { - // Create a new image object with schema fields - const newImage = imageData || { - imageUrl, - pid: product.id || 0, - iid: Math.floor(Math.random() * 10000), // Generate a temporary iid - type: 0, - order: images.length, - width: 0, - height: 0, - hidden: 0 - }; - - // If imageData is a string, convert it to an object - if (typeof newImage === 'string') { - newImage = { - imageUrl: newImage, - pid: product.id || 0, - iid: Math.floor(Math.random() * 10000), - type: 0, - order: images.length, - width: 0, - height: 0, - hidden: 0 - }; - } - - images.push(newImage); - } - - // Update the product_images field - product.product_images = images; - - return newData; - }; // Update handleDragEnd to work with the updated product data structure const handleDragEnd = (event: DragEndEvent) => { @@ -524,13 +456,8 @@ export const ImageUploadStep = ({ }); // Update both products' image data fields - 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}`}`); @@ -602,44 +529,41 @@ export const ImageUploadStep = ({ setActiveImage(null); }; - // Function to handle image upload - update product data - const handleImageUpload = async (files: FileList | File[], productIndex: number) => { + // Function to handle image upload + const handleImageUpload = async (files: FileList | File[], productIndex: number): Promise => { if (!files || files.length === 0) return; - for (let i = 0; i < files.length; i++) { - const file = files[i]; + const fileArray = Array.from(files); + + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i]; - // Add placeholder for this image - const newImage: ProductImageSortable = { - id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID + // Create initial image data + let imageData: ProductImageSortable = { + id: `image-${productIndex}-${Date.now()}-${i}`, productIndex, imageUrl: '', loading: true, fileName: file.name, - // Add schema fields - pid: data[productIndex].id || 0, - iid: 0, // Will be assigned by server + pid: String(data[productIndex].id || ''), + iid: '', type: 0, - order: productImages.filter(img => img.productIndex === productIndex).length + i, - width: 0, - height: 0, - hidden: 0 + order: productImages.filter(img => img.productIndex === productIndex).length + i }; - setProductImages(prev => [...prev, newImage]); - - // Create form data for upload - const formData = new FormData(); - formData.append('image', file); - formData.append('productIndex', productIndex.toString()); - formData.append('upc', data[productIndex].upc || ''); - formData.append('supplier_no', data[productIndex].supplier_no || ''); + // Add to state + setProductImages(prev => [...prev, imageData]); try { - // Upload the image - const response = await fetch(`${config.apiUrl}/import/upload-image`, { + // Create form data for upload + const formData = new FormData(); + formData.append('file', file); + formData.append('productIndex', String(productIndex)); + + // Upload the file + const response = await fetch(`${config.apiUrl}/upload-image`, { method: 'POST', - body: formData, + body: formData }); if (!response.ok) { @@ -648,38 +572,32 @@ export const ImageUploadStep = ({ const result = await response.json(); - // Update the image URL in our state - setProductImages(prev => - prev.map(img => - (img.loading && img.productIndex === productIndex && img.fileName === file.name) - ? { - ...img, - imageUrl: result.imageUrl, - loading: false, - // Update schema fields if returned from server - iid: result.iid || img.iid, - width: result.width || img.width, - height: result.height || img.height - } - : img - ) - ); - - // Update the product data with the new image URL - addImageToProduct(productIndex, result.imageUrl, result); - - toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`); + // Update the image data with server response + if (result) { + imageData = { + ...imageData, + imageUrl: result.imageUrl, + loading: false, + iid: result.iid || imageData.iid, + width: result.width || imageData.width, + height: result.height || imageData.height + }; + + // Update the product images state + setProductImages(prev => + prev.map(img => + img.id === imageData.id ? imageData : img + ) + ); + } } catch (error) { - console.error('Upload error:', error); + console.error('Error uploading image:', error); + toast.error('Failed to upload image'); - // Remove the failed image from our state + // Remove the placeholder image on error setProductImages(prev => - prev.filter(img => - !(img.loading && img.productIndex === productIndex && img.fileName === file.name) - ) + prev.filter(img => img.id !== imageData.id) ); - - toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`); } } }; @@ -717,7 +635,7 @@ export const ImageUploadStep = ({ // Function to find product index by identifier const findProductByIdentifier = (identifier: string): number => { // Try to match against supplier_no, upc, SKU, or name - return data.findIndex(product => { + return data.findIndex((product: Product) => { // Skip if product is missing all identifiers if (!product.supplier_no && !product.upc && !product.sku && !product.name) { return false; @@ -756,7 +674,7 @@ export const ImageUploadStep = ({ }; // Function to handle bulk image upload - const handleBulkUpload = async (files: File[]) => { + const handleBulkUpload = async (files: File[]): Promise => { if (!files.length) return; setProcessingBulk(true); @@ -765,26 +683,80 @@ export const ImageUploadStep = ({ for (const file of files) { // Extract identifiers from filename const identifiers = extractIdentifiers(file.name); - let assigned = false; - // Try to match each identifier + // Try to find matching product + let productIndex = -1; for (const identifier of identifiers) { - const productIndex = findProductByIdentifier(identifier); - - if (productIndex !== -1) { - // Found a match, upload to this product - await handleImageUpload([file], productIndex); - assigned = true; - break; - } + productIndex = findProductByIdentifier(identifier); + if (productIndex !== -1) break; } - // If no match was found, add to unassigned - if (!assigned) { - unassigned.push({ - file, - previewUrl: createPreviewUrl(file) - }); + if (productIndex === -1) { + // If no match found, add to unassigned images + const previewUrl = createPreviewUrl(file); + setUnassignedImages(prev => [...prev, { file, previewUrl }]); + } else { + // If match found, add to product images + let imageData: ProductImageSortable = { + id: `image-${productIndex}-${Date.now()}`, + productIndex, + imageUrl: '', + loading: true, + fileName: file.name, + pid: String(data[productIndex].id || ''), + iid: '', + type: 0, + order: productImages.filter(img => img.productIndex === productIndex).length + }; + + // Add to state + setProductImages(prev => [...prev, imageData]); + + try { + // Create form data for upload + const formData = new FormData(); + formData.append('file', file); + formData.append('productIndex', String(productIndex)); + + // Upload the file + const response = await fetch(`${config.apiUrl}/upload-image`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Failed to upload image'); + } + + const result = await response.json(); + + // Update the image data with server response + if (result) { + imageData = { + ...imageData, + imageUrl: result.imageUrl, + loading: false, + iid: result.iid || imageData.iid, + width: result.width || imageData.width, + height: result.height || imageData.height + }; + + // Update the product images state + setProductImages(prev => + prev.map(img => + img.id === imageData.id ? imageData : img + ) + ); + } + } catch (error) { + console.error('Error uploading image:', error); + toast.error('Failed to upload image'); + + // Remove the placeholder image on error + setProductImages(prev => + prev.filter(img => img.id !== imageData.id) + ); + } } } @@ -938,18 +910,13 @@ export const ImageUploadStep = ({ } }, [data, file, onSubmit, productImages]); - // 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; + // Function to get full image URL + const getFullImageUrl = (urlInput: string | undefined | null): string => { + if (!urlInput) return ''; + if (urlInput.startsWith('http://') || urlInput.startsWith('https://')) { + return urlInput; } - - // 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}`; + return `${config.apiUrl}/images/${urlInput}`; }; // Generic dropzone component @@ -1057,7 +1024,7 @@ export const ImageUploadStep = ({ - {data.map((product: any, productIndex: number) => ( + {data.map((product: Product, productIndex: number) => ( {product.name || `Product #${productIndex + 1}`} @@ -1342,112 +1309,76 @@ export const ImageUploadStep = ({ }; // Add a URL input component that doesn't expand/collapse - const ImageUrlInput = ({ productIndex }: { productIndex: number }) => { - // Use a stable format that won't get affected by DndContext events - return ( -
{ - e.preventDefault(); - if (urlInputs[productIndex]) { - handleAddImageFromUrl(productIndex, urlInputs[productIndex]); - } - }} - > - updateUrlInput(productIndex, e.target.value)} - className="text-xs h-8 max-w-[180px]" - /> - -
- ); - }; - // Handle adding an image from a URL - simplified to skip server - const handleAddImageFromUrl = async (productIndex: number, url: string) => { - if (!url || !url.trim()) { - toast.error("Please enter a valid URL"); - return; - } + // Function to handle adding image from URL + const handleAddImageFromUrl = async (productIndex: number, urlInput: string): Promise => { + if (!urlInput) return; + + // Set processing state + setProcessingUrls(prev => ({ ...prev, [productIndex]: true })); + + // Create initial image data + let newImage: ProductImageSortable = { + id: `image-${productIndex}-${Date.now()}`, + productIndex, + imageUrl: urlInput, + loading: true, + fileName: urlInput.split('/').pop() || 'url-image', + pid: String(data[productIndex].id || ''), + iid: '', + type: 0, + order: productImages.filter(img => img.productIndex === productIndex).length + }; + + // Add to state + setProductImages(prev => [...prev, newImage]); try { - // Set processing state - setProcessingUrls(prev => ({ ...prev, [productIndex]: true })); - // Validate URL format - let validatedUrl = url.trim(); - - // Add protocol if missing - if (!validatedUrl.startsWith('http://') && !validatedUrl.startsWith('https://')) { - validatedUrl = `https://${validatedUrl}`; + const validUrl = getFullImageUrl(urlInput); + if (!validUrl) { + throw new Error('Invalid URL format'); } - // Basic URL validation - try { - new URL(validatedUrl); - } catch (e) { - toast.error("Invalid URL format. Please enter a valid URL"); - setProcessingUrls(prev => ({ ...prev, [productIndex]: false })); - return; - } - - // Create a unique ID for this image - const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - // Get the next order value for this product - const nextOrder = productImages - .filter(img => img.productIndex === productIndex) - .length; - - // Create the new image object with the URL - const newImage: ProductImageSortable = { - id: imageId, - productIndex, - imageUrl: validatedUrl, - loading: false, // We're not loading from server, so it's ready immediately - fileName: "From URL", - // Add schema fields - pid: data[productIndex].id || 0, - iid: Math.floor(Math.random() * 10000), // Generate a temporary iid - type: 0, - order: nextOrder, - width: 0, - height: 0, - hidden: 0 + // Update image data with validated URL + newImage = { + ...newImage, + imageUrl: validUrl, + loading: false }; - // Add the image directly to the product images list - setProductImages(prev => [...prev, newImage]); + // Update product images state + setProductImages(prev => + prev.map(img => + img.id === newImage.id ? newImage : img + ) + ); - // Update the product data with the new image URL - addImageToProduct(productIndex, validatedUrl, newImage); + // Clear URL input + updateUrlInput(productIndex, ''); - // Clear the URL input field on success - setUrlInputs(prev => ({ ...prev, [productIndex]: '' })); - - toast.success(`Image URL added to ${data[productIndex].name || `Product #${productIndex + 1}`}`); + toast.success(`Image added from URL for ${data[productIndex].name || `Product #${productIndex + 1}`}`); } catch (error) { - console.error('Add image from URL error:', error); - toast.error(`Failed to add image URL: ${error instanceof Error ? error.message : 'Unknown error'}`); + console.error('Error adding image from URL:', error); + toast.error('Failed to add image from URL'); + + // Remove the placeholder image on error + setProductImages(prev => + prev.filter(img => img.id !== newImage.id) + ); } finally { + // Clear processing state setProcessingUrls(prev => ({ ...prev, [productIndex]: false })); } }; + // Function to update URL input + const updateUrlInput = (productIndex: number, value: string): void => { + setUrlInputs((prev: { [key: number]: string }) => ({ ...prev, [productIndex]: value })); + }; + // Function to copy text to clipboard - const copyToClipboard = (text: string, key: string) => { + const copyToClipboard = (text: string, key: string): void => { if (!text || text === 'N/A') return; navigator.clipboard.writeText(text) @@ -1500,11 +1431,6 @@ export const ImageUploadStep = ({ ); }; - // Update the URL input value - const updateUrlInput = (productIndex: number, value: string) => { - setUrlInputs(prev => ({ ...prev, [productIndex]: value })); - }; - return (
{/* Header - fixed at top */} @@ -1555,7 +1481,7 @@ export const ImageUploadStep = ({ )}
- {data.map((product: any, index: number) => ( + {data.map((product: Product, index: number) => ( ({ e.preventDefault(); if (urlInputs[index]) { handleAddImageFromUrl(index, urlInputs[index]); - updateUrlInput(index, ''); } }} > diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx index a084af3..f0d13d6 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -535,7 +535,6 @@ const SubLineSelector = React.memo(({ // Add this new component before the MatchColumnsStep component const FieldSelector = React.memo(({ column, - isUnmapped = false, fieldCategories, allFields, onChange, @@ -794,18 +793,13 @@ export const MatchColumnsStep = React.memo(({ const fieldOptions = fieldOptionsData || { suppliers: [], companies: [] }; // Create a stable identity for these queries to avoid re-renders - const stableFieldOptions = useMemo(() => fieldOptionsData || { suppliers: [], companies: [] }, [fieldOptionsData]); const stableProductLines = useMemo(() => productLines || [], [productLines]); const stableSublines = useMemo(() => sublines || [], [sublines]); const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]); const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]); // Type guard for suppliers and companies - const hasSuppliers = (options: any): options is { suppliers: any[] } => - options && Array.isArray(options.suppliers); - const hasCompanies = (options: any): options is { companies: any[] } => - options && Array.isArray(options.companies); // Check if a field is covered by global selections const isFieldCoveredByGlobalSelections = useCallback((key: string) => { @@ -976,20 +970,6 @@ export const MatchColumnsStep = React.memo(({ }, [availableFields]); // Group all fields by category (for editing mapped columns) - const allFieldCategories = useMemo(() => { - return [ - { name: "Basic Info", fields: getFieldsByKeyPrefix('basic', allFields) }, - { name: "Product", fields: getFieldsByKeyPrefix('product', allFields) }, - { name: "Inventory", fields: getFieldsByKeyPrefix('inventory', allFields) }, - { name: "Pricing", fields: getFieldsByKeyPrefix('pricing', allFields) }, - { name: "Other", fields: allFields.filter(f => - !f.key.startsWith('basic') && - !f.key.startsWith('product') && - !f.key.startsWith('inventory') && - !f.key.startsWith('pricing') - ) } - ].filter(category => category.fields.length > 0); - }, [allFields, getFieldsByKeyPrefix]); // Group available fields by category (for unmapped columns) const availableFieldCategories = useMemo(() => { diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/utils/normalizeTableData.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/utils/normalizeTableData.ts index 1f01fd1..46fa4dd 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/utils/normalizeTableData.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/utils/normalizeTableData.ts @@ -15,30 +15,30 @@ export const normalizeTableData = (columns: Columns, data: (key) => key.toLowerCase() === curr?.toLowerCase(), )! const booleanMatch = field.fieldType.booleanMatches?.[booleanMatchKey] - acc[column.value] = booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr) + acc[column.value] = (booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)) as Data[T] } else { - acc[column.value] = normalizeCheckboxValue(curr) + acc[column.value] = normalizeCheckboxValue(curr) as Data[T] } return acc } case ColumnType.matched: { - acc[column.value] = curr === "" ? undefined : curr + acc[column.value] = (curr === "" ? undefined : curr) as Data[T] return acc } case ColumnType.matchedMultiInput: { const field = fields.find((field) => field.key === column.value)! if (curr) { const separator = field.fieldType.type === "multi-input" ? field.fieldType.separator || "," : "," - acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean) + acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean) as Data[T] } else { - acc[column.value] = undefined + acc[column.value] = undefined as Data[T] } return acc } case ColumnType.matchedSelect: case ColumnType.matchedSelectOptions: { - const matchedOption = column.matchedOptions.find(({ entry, value }) => entry === curr) - acc[column.value] = matchedOption?.value || undefined + const matchedOption = column.matchedOptions.find(({ entry }) => entry === curr) + acc[column.value] = (matchedOption?.value || undefined) as Data[T] return acc } case ColumnType.matchedMultiSelect: { @@ -50,9 +50,9 @@ export const normalizeTableData = (columns: Columns, data: const matchedOption = column.matchedOptions.find(({ entry: optEntry }) => optEntry === entry) return matchedOption?.value }).filter(Boolean) as string[] - acc[column.value] = values.length ? values : undefined + acc[column.value] = (values.length ? values : undefined) as Data[T] } else { - acc[column.value] = undefined + acc[column.value] = undefined as Data[T] } return acc } diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/columns.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/columns.tsx index 000bce9..a042ba3 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/columns.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/columns.tsx @@ -1,8 +1,7 @@ -import DataGrid, { Column, FormatterProps, useRowSelection } from "react-data-grid" +import { Column, FormatterProps, useRowSelection } from "react-data-grid" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { Label } from "@/components/ui/label" import type { RawData } from "../../../types" -import { cn } from "@/lib/utils" const SELECT_COLUMN_KEY = "select-row" @@ -58,7 +57,7 @@ export const generateSelectionColumns = (data: RawData[]) => { key: index.toString(), name: `Column ${index + 1}`, width: 150, - formatter: ({ row }) => ( + formatter: ({ row }: { row: RawData }) => (
{row[index]}
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx index 19b5272..cc565fb 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx @@ -6,13 +6,13 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep" import { mapWorkbook } from "../utils/mapWorkbook" import { ValidationStepNew } from "./ValidationStepNew" import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep" -import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep" import { exceedsMaxRecords } from "../utils/exceedsMaxRecords" import { useRsi } from "../hooks/useRsi" -import type { RawData } from "../types" +import type { RawData, Data } from "../types" import { Progress } from "@/components/ui/progress" import { useToast } from "@/hooks/use-toast" +import { addErrorsAndRunHooks } from "./ValidationStepNew/utils/dataMutations" export enum StepType { upload = "upload", @@ -185,7 +185,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { // Apply global selections to each row of data if they exist const dataWithGlobalSelections = globalSelections - ? dataWithMeta.map(row => { + ? dataWithMeta.map((row: Data & { __errors?: any; __index?: string }) => { const newRow = { ...row }; if (globalSelections.supplier) newRow.supplier = globalSelections.supplier; if (globalSelections.company) newRow.company = globalSelections.company; diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/components/ExampleTable.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/components/ExampleTable.tsx deleted file mode 100644 index 2b432fe..0000000 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/components/ExampleTable.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Fields } from "../../../types" -import { useMemo } from "react" -import { Table } from "../../../components/Table" -import { generateColumns } from "./columns" -import { generateExampleRow } from "../utils/generateExampleRow" - -interface Props { - fields: Fields -} - -export const ExampleTable = ({ fields }: Props) => { - const data = useMemo(() => generateExampleRow(fields), [fields]) - const columns = useMemo(() => generateColumns(fields), [fields]) - - return ( -
- - - ) -} diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/components/FadingOverlay.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/components/FadingOverlay.tsx deleted file mode 100644 index 4548c69..0000000 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/components/FadingOverlay.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export const FadingOverlay = () => ( -
-) diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/utils/generateExampleRow.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/utils/generateExampleRow.ts deleted file mode 100644 index 547a67c..0000000 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/utils/generateExampleRow.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Field, Fields } from "../../../types" - -const titleMap: Record["fieldType"]["type"], string> = { - checkbox: "Boolean", - select: "Options", - input: "Text", -} - -export const generateExampleRow = (fields: Fields) => [ - fields.reduce((acc, field) => { - acc[field.key as T] = field.example || titleMap[field.fieldType.type] - return acc - }, {} as Record), -] diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/utils/getDropZoneBorder.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/utils/getDropZoneBorder.ts deleted file mode 100644 index 72ad862..0000000 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/utils/getDropZoneBorder.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getDropZoneBorder = (color: string) => { - return { - bgGradient: `repeating-linear(0deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(90deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(180deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(270deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px)`, - backgroundSize: "2px 100%, 100% 2px, 2px 100% , 100% 2px", - backgroundPosition: "0 0, 0 0, 100% 0, 0 100%", - backgroundRepeat: "no-repeat", - borderRadius: "4px", - } -} diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx index 42d7e05..c3f2eaf 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/SearchableTemplateSelect.tsx @@ -50,7 +50,7 @@ const SearchableTemplateSelect: React.FC = ({ const [searchTerm, setSearchTerm] = useState(""); const [selectedBrand, setSelectedBrand] = useState(null); const [open, setOpen] = useState(false); - const [error, setError] = useState(null); + const [] = useState(null); // Debug logging useEffect(() => { diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx index aa1d4ca..6d80b97 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/TemplateManager.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -22,7 +22,7 @@ import { CommandItem, CommandList } from '@/components/ui/command' -import { ChevronsUpDown, Check, X } from 'lucide-react' +import { ChevronsUpDown, Check } from 'lucide-react' import { cn } from '@/lib/utils' import { Template } from '../hooks/useValidationState' import { toast } from 'sonner' diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationStep.tsx deleted file mode 100644 index 110122b..0000000 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationStep.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useEffect, useCallback } from 'react'; -import { useValidationState } from '../hooks/useValidationState'; -import ValidationTable from './ValidationTable'; -import { Fields, Field } from '../../../types'; -import { RowData } from '../hooks/useValidationState'; - -interface ValidationStepProps { - data: RowData[]; - fields: Fields; - onContinue?: (data: RowData[]) => void; - onBack?: () => void; - initialValidationErrors?: Map>; - initialValidationState?: Map; -} - -export const ValidationStep = ({ - data, - fields, - onContinue, - onBack, - initialValidationErrors, - initialValidationState, -}: ValidationStepProps) => { - const { - data: rowData, - validationErrors, - rowValidationStatus, - validateRow, - hasErrors, - fields: fieldsWithOptions, - rowSelection, - setRowSelection, - updateRow, - isValidatingUpc, - validatingUpcRows, - filters, - templates, - applyTemplate, - getTemplateDisplayText, - } = useValidationState({ - initialData: data, - fields, - onNext: onContinue, - onBack, - }); - - return ( -
- -
- ); -}; diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx index 4429bbe..d3e04d9 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/CheckboxCell.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Field } from '../../../../types' import { Checkbox } from '@/components/ui/checkbox' import { cn } from '@/lib/utils' diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx index 2d4f18a..0306537 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/InputCell.tsx @@ -16,7 +16,6 @@ interface InputCellProps { } const InputCell = ({ - field, value, onChange, onStartEdit, diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx index 5b2691d..e7f3123 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/MultiInputCell.tsx @@ -1,6 +1,5 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' +import React, { useState, useCallback, useMemo } from 'react' import { Field } from '../../../../types' -import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' @@ -15,12 +14,6 @@ interface FieldOption { value: string; } -// Define extended field type that includes multi-select -interface MultiSelectFieldType { - type: 'multi-select'; - options?: readonly FieldOption[]; - separator?: string; -} interface MultiInputCellProps { field: Field diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx index ae23a74..b991812 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells/SelectCell.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import { useState } from 'react' import { Field } from '../../../../types' import { Check, ChevronsUpDown } from 'lucide-react' import { Button } from '@/components/ui/button' diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx index 17385fd..a6e13a7 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useAiValidation.tsx @@ -1,8 +1,9 @@ import { useState, useCallback } from 'react'; import { toast } from 'sonner'; import { getApiUrl, RowData } from './useValidationState'; -import { Fields } from '../../../types'; -import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations'; +import { Fields, InfoWithSource, ErrorSources } from '../../../types'; +import { Meta } from '../types'; +import { addErrorsAndRunHooks } from '../utils/dataMutations'; import * as Diff from 'diff'; // Define interfaces for AI validation @@ -81,6 +82,51 @@ export const useAiValidation = ( // Track reverted changes const [revertedChanges, setRevertedChanges] = useState>(new Set()); + // Create an adapter for the rowHook to match the expected RowHook type + const adaptedRowHook = rowHook ? async ( + row: RowData ): Promise => { + // Call the original hook + const result = await rowHook(row); + // Extract Meta-specific properties + const { __index, __errors } = result; + // Return a Meta object with properly typed errors + return { + __index: __index || row.__index || '', + __errors: __errors ? + Object.fromEntries( + Object.entries(__errors).map(([key, value]) => { + const errorArray = Array.isArray(value) ? value : [value]; + return [key, { + message: errorArray[0].message, + level: errorArray[0].level, + source: ErrorSources.Row + } as InfoWithSource] + }) + ) : null + }; + } : undefined; + + // Create an adapter for the tableHook to match the expected TableHook type + const adaptedTableHook = tableHook ? async (rows: RowData[]): Promise => { + // Call the original hook + const results = await tableHook(rows); + // Extract Meta-specific properties from each result + return results.map((result, index) => ({ + __index: result.__index || rows[index].__index || '', + __errors: result.__errors ? + Object.fromEntries( + Object.entries(result.__errors).map(([key, value]) => { + const errorArray = Array.isArray(value) ? value : [value]; + return [key, { + message: errorArray[0].message, + level: errorArray[0].level, + source: ErrorSources.Table + } as InfoWithSource] + }) + ) : null + })); + } : undefined; + // Get field display value const getFieldDisplayValue = useCallback((fieldKey: string, value: any): string => { const field = fields.find(f => f.key === fieldKey); @@ -543,8 +589,8 @@ export const useAiValidation = ( const validatedData = await addErrorsAndRunHooks( processedData, fields, - rowHook, - tableHook + adaptedRowHook, + adaptedTableHook ); // Update the component state with the validated data @@ -620,7 +666,7 @@ export const useAiValidation = ( elapsedSeconds: prev.elapsedSeconds })); } - }, [isAiValidating, data, aiValidationProgress.estimatedSeconds, aiValidationProgress.promptLength, fields, rowHook, tableHook]); + }, [isAiValidating, data, aiValidationProgress.estimatedSeconds, aiValidationProgress.promptLength, fields, adaptedRowHook, adaptedTableHook]); return { isAiValidating, diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx index 1aacf76..a3d6e2f 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useTemplates.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useCallback } from 'react' -import { Data } from '../../../types' import { RowSelectionState } from '@tanstack/react-table' import { Template, RowData, getApiUrl } from './useValidationState' @@ -100,7 +99,11 @@ export const useTemplates = ( body: JSON.stringify({ company: template.company, product_type: type, - ...template + ...Object.fromEntries( + Object.entries(template).filter(([key]) => + !['company', 'product_type'].includes(key) + ) + ) }), }) diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx index fcb24db..2e3567f 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/hooks/useValidation.tsx @@ -1,8 +1,7 @@ import { useCallback } from 'react' -import type { Data, Field, Fields, RowHook, TableHook } from '../../../types' -import type { Meta } from '../../ValidationStep/types' +import type { Field, Fields, RowHook, TableHook } from '../../../types' +import type { Meta } from '../types' import { ErrorSources } from '../../../types' -import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations' import { RowData } from './useValidationState' interface ValidationError { @@ -10,6 +9,12 @@ interface ValidationError { level: 'info' | 'warning' | 'error' } +interface InfoWithSource { + message: string + level: 'info' | 'warning' | 'error' + source: ErrorSources +} + export const useValidation = ( fields: Fields, rowHook?: RowHook, @@ -62,31 +67,31 @@ export const useValidation = ( // Validate a single row const validateRow = useCallback(async ( - row: Data, + row: RowData, rowIndex: number, - allRows: Data[] + allRows: RowData[] ): Promise => { // Run field-level validations const fieldErrors: Record = {} fields.forEach(field => { - const value = row[field.key] - const errors = validateField(value, field) + const value = row[String(field.key) as keyof typeof row] + const errors = validateField(value, field as Field) if (errors.length > 0) { - fieldErrors[field.key] = errors + fieldErrors[String(field.key)] = errors } }) // Special validation for supplier and company fields - if (fields.some(field => field.key === 'supplier' as any) && !row.supplier) { + if (fields.some(field => String(field.key) === 'supplier') && !row.supplier) { fieldErrors['supplier'] = [{ message: 'Supplier is required', level: 'error' }] } - if (fields.some(field => field.key === 'company' as any) && !row.company) { + if (fields.some(field => String(field.key) === 'company') && !row.company) { fieldErrors['company'] = [{ message: 'Company is required', level: 'error' @@ -94,7 +99,10 @@ export const useValidation = ( } // Run row hook if provided - let rowHookResult: Meta = { __errors: {} } + let rowHookResult: Meta = { + __index: row.__index || String(rowIndex), + __errors: {} + } if (rowHook) { try { rowHookResult = await rowHook(row, rowIndex, allRows) @@ -104,52 +112,82 @@ export const useValidation = ( } // Merge field errors and row hook errors - const mergedErrors: Record = { ...fieldErrors } + const mergedErrors: Record = {} + // Convert field errors to InfoWithSource + Object.entries(fieldErrors).forEach(([key, errors]) => { + if (errors.length > 0) { + mergedErrors[key] = { + message: errors[0].message, + level: errors[0].level, + source: ErrorSources.Row + } + } + }) + + // Merge row hook errors if (rowHookResult.__errors) { - Object.entries(rowHookResult.__errors).forEach(([key, errors]) => { - const errorArray = Array.isArray(errors) ? errors : [errors] - mergedErrors[key] = [ - ...(mergedErrors[key] || []), - ...errorArray - ] + Object.entries(rowHookResult.__errors).forEach(([key, error]) => { + if (error) { + mergedErrors[key] = error + } }) } return { + __index: row.__index || String(rowIndex), __errors: mergedErrors } }, [fields, validateField, rowHook]) // Validate all data at the table level const validateTable = useCallback(async (data: RowData[]): Promise => { - if (!tableHook) return data.map(() => ({ __errors: {} })) + if (!tableHook) { + return data.map((row, index) => ({ + __index: row.__index || String(index), + __errors: {} + })) + } try { const tableResults = await tableHook(data) // Process table validation results - return tableResults.map(result => { + return tableResults.map((result, index) => { // Ensure errors are properly formatted - const formattedErrors: Record = {} + const formattedErrors: Record = {} if (result.__errors) { - Object.entries(result.__errors).forEach(([key, errors]) => { - formattedErrors[key] = Array.isArray(errors) ? errors : [errors] + Object.entries(result.__errors).forEach(([key, error]) => { + if (error) { + formattedErrors[key] = { + ...error, + source: ErrorSources.Table + } + } }) } - return { __errors: formattedErrors } + return { + __index: result.__index || data[index].__index || String(index), + __errors: formattedErrors + } }) } catch (error) { console.error('Error in table hook:', error) - return data.map(() => ({ __errors: {} })) + return data.map((row, index) => ({ + __index: row.__index || String(index), + __errors: {} + })) } }, [tableHook]) // Validate unique fields across the table const validateUnique = useCallback((data: RowData[]) => { - const uniqueErrors: Meta[] = data.map(() => ({ __errors: {} })) + const uniqueErrors: Meta[] = data.map((row, index) => ({ + __index: row.__index || String(index), + __errors: {} + })) // Find fields with unique validation const uniqueFields = fields.filter(field => @@ -173,7 +211,7 @@ export const useValidation = ( // Build value map data.forEach((row, rowIndex) => { - const value = String(row[key] || '') + const value = String(row[String(key) as keyof typeof row] || '') // Skip empty values if allowed if (allowEmpty && (value === '' || value === undefined || value === null)) { @@ -188,20 +226,17 @@ export const useValidation = ( }) // Add errors for duplicate values - valueMap.forEach((rowIndexes, value) => { + valueMap.forEach((rowIndexes) => { if (rowIndexes.length > 1) { // Add error to all duplicate rows rowIndexes.forEach(rowIndex => { const rowErrors = uniqueErrors[rowIndex].__errors || {} - rowErrors[String(key)] = [ - ...(rowErrors[String(key)] || []), - { - message: errorMessage, - level, - source: ErrorSources.Table - } - ] + rowErrors[String(key)] = { + message: errorMessage, + level, + source: ErrorSources.Table + } uniqueErrors[rowIndex].__errors = rowErrors }) @@ -248,10 +283,10 @@ export const useValidation = ( }, [validateRow, validateUnique, validateTable]) return { + validateData, validateField, validateRow, validateTable, - validateUnique, - validateData + validateUnique } } \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx index 90e1b39..b5c6235 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/index.tsx @@ -1,4 +1,3 @@ -import React from 'react' import ValidationContainer from './components/ValidationContainer' import { Props } from './hooks/useValidationState' diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts new file mode 100644 index 0000000..87143e8 --- /dev/null +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types.ts @@ -0,0 +1,5 @@ +import { InfoWithSource } from "../../types" + +export type Meta = { __index: string; __errors?: Error | null } +export type Error = { [key: string]: InfoWithSource } +export type Errors = { [id: string]: Error } \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts index 80b390d..b0847d4 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/types/index.ts @@ -1,4 +1,4 @@ -import { InfoWithSource, ErrorLevel } from "../../types" +import { InfoWithSource, ErrorLevel } from "../../../types" // Define our own Error type that's compatible with the original export interface ErrorType { diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts new file mode 100644 index 0000000..6e3bb0e --- /dev/null +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/dataMutations.ts @@ -0,0 +1,176 @@ +import type { Data, Fields, Info, RowHook, TableHook } from "../../../types" +import type { Meta, Error, Errors } from "../types" +import { v4 } from "uuid" +import { ErrorSources } from "../../../types" + + +type DataWithMeta = Data & Meta & { + __index?: string; + __errors?: Error | null; +} + +export const addErrorsAndRunHooks = async ( + data: (Data & Partial)[], + fields: Fields, + rowHook?: RowHook, + tableHook?: TableHook, + changedRowIndexes?: number[], +): Promise[]> => { + const errors: Errors = {} + + const addError = (source: ErrorSources, rowIndex: number, fieldKey: string, error: Info) => { + errors[rowIndex] = { + ...errors[rowIndex], + [fieldKey]: { ...error, source }, + } + } + + let processedData = [...data] as DataWithMeta[] + + if (tableHook) { + const tableResults = await tableHook(processedData) + processedData = tableResults.map((result, index) => ({ + ...processedData[index], + ...result + })) + } + + if (rowHook) { + if (changedRowIndexes) { + for (const index of changedRowIndexes) { + const rowResult = await rowHook(processedData[index], index, processedData) + processedData[index] = { + ...processedData[index], + ...rowResult + } + } + } else { + const rowResults = await Promise.all( + processedData.map(async (value, index) => { + const result = await rowHook(value, index, processedData) + return { + ...value, + ...result + } + }) + ) + processedData = rowResults + } + } + + fields.forEach((field) => { + const fieldKey = field.key as string + field.validations?.forEach((validation) => { + switch (validation.rule) { + case "unique": { + const values = processedData.map((entry) => { + const value = entry[fieldKey as keyof typeof entry] + return value + }) + + const taken = new Set() // Set of items used at least once + const duplicates = new Set() // Set of items used multiple times + + values.forEach((value) => { + if (validation.allowEmpty && !value) { + // If allowEmpty is set, we will not validate falsy fields such as undefined or empty string. + return + } + + if (taken.has(value)) { + duplicates.add(value) + } else { + taken.add(value) + } + }) + + values.forEach((value, index) => { + if (duplicates.has(value)) { + addError(ErrorSources.Table, index, fieldKey, { + level: validation.level || "error", + message: validation.errorMessage || "Field must be unique", + }) + } + }) + break + } + case "required": { + const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData + dataToValidate.forEach((entry, index) => { + const realIndex = changedRowIndexes ? changedRowIndexes[index] : index + const value = entry[fieldKey as keyof typeof entry] + if (value === null || value === undefined || value === "") { + addError(ErrorSources.Row, realIndex, fieldKey, { + level: validation.level || "error", + message: validation.errorMessage || "Field is required", + }) + } + }) + break + } + case "regex": { + const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData + const regex = new RegExp(validation.value, validation.flags) + dataToValidate.forEach((entry, index) => { + const realIndex = changedRowIndexes ? changedRowIndexes[index] : index + const value = entry[fieldKey as keyof typeof entry] + const stringValue = value?.toString() ?? "" + if (!stringValue.match(regex)) { + addError(ErrorSources.Row, realIndex, fieldKey, { + level: validation.level || "error", + message: + validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `, + }) + } + }) + break + } + } + }) + }) + + return processedData.map((value, index) => { + // This is required only for table. Mutates to prevent needless rerenders + const result: DataWithMeta = { ...value } + if (!result.__index) { + result.__index = v4() + } + + // If we are validating all indexes, or we did full validation on this row - apply all errors + if (!changedRowIndexes || changedRowIndexes.includes(index)) { + if (errors[index]) { + return { ...result, __errors: errors[index] } + } + + if (!errors[index] && result.__errors) { + return { ...result, __errors: null } + } + } + // if we have not validated this row, keep it's row errors but apply global error changes + else { + // at this point errors[index] contains only table source errors, previous row and table errors are in value.__errors + const hasRowErrors = + result.__errors && Object.values(result.__errors).some((error) => error.source === ErrorSources.Row) + + if (!hasRowErrors) { + if (errors[index]) { + return { ...result, __errors: errors[index] } + } + return result + } + + const errorsWithoutTableError = Object.entries(result.__errors!).reduce((acc, [key, value]) => { + if (value.source === ErrorSources.Row) { + acc[key] = value + } + return acc + }, {} as Error) + + const newErrors = { ...errorsWithoutTableError, ...errors[index] } + + return { ...result, __errors: newErrors } + } + + return result + }) +} \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts index 3ceed2d..e4612dc 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/errorUtils.ts @@ -1,5 +1,4 @@ -import { InfoWithSource } from '../../../types' -import { ErrorType, ErrorTypes } from '../types/index' +import { ErrorType } from '../types/index' /** * Converts an InfoWithSource or similar error object to our Error type diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts index 36f0a1a..4e5f69a 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/utils/validationUtils.ts @@ -1,5 +1,5 @@ import { Field, Data, ErrorSources } from '../../../types' -import { ErrorType, ErrorTypes } from '../types/index' +import { ErrorType } from '../types/index' /** * Formats a price value to a consistent format @@ -29,7 +29,10 @@ export const formatPrice = (value: string | number): string => { * @returns True if the field is a price field */ export const isPriceField = (field: Field): boolean => { - return !!field.fieldType.price + const fieldType = field.fieldType; + return (fieldType.type === 'input' || fieldType.type === 'multi-input') && + 'price' in fieldType && + !!fieldType.price; } /** diff --git a/inventory/src/lib/react-spreadsheet-import/src/types.ts b/inventory/src/lib/react-spreadsheet-import/src/types.ts index c53c4dc..a4fee3b 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/types.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/types.ts @@ -1,4 +1,4 @@ -import type { Meta } from "./steps/ValidationStep/types" +import type { Meta } from "./steps/ValidationStepNew/types" import type { DeepReadonly } from "ts-essentials" import type { TranslationsRSIProps } from "./translationsRSIProps" import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep" @@ -53,9 +53,9 @@ export type RsiProps = { export type RawData = (string | undefined)[] -export type Data = { - [key in T]?: string | boolean | undefined -} & { +export type DataValue = string | boolean | string[] | undefined + +export type Data = Partial> & { supplier?: string company?: string line?: string diff --git a/inventory/src/types/react-data-grid.d.ts b/inventory/src/types/react-data-grid.d.ts new file mode 100644 index 0000000..a3346ab --- /dev/null +++ b/inventory/src/types/react-data-grid.d.ts @@ -0,0 +1,100 @@ +declare module 'react-data-grid' { + export interface FormatterProps { + row: TRow; + column: Column; + isCellSelected: boolean; + onRowChange: (row: TRow) => void; + rowIdx: number; + } + + export interface Column { + /** The name of the column. By default it will be displayed in the header cell */ + name: string | JSX.Element; + /** A unique key to distinguish each column */ + key: string; + /** Column width. If not specified, it will be determined automatically based on grid width and specified widths of other columns */ + width?: number | string; + /** Minimum column width in px. */ + minWidth?: number; + /** Maximum column width in px. */ + maxWidth?: number; + cellClass?: string | ((row: TRow) => string); + headerCellClass?: string; + summaryCellClass?: string | ((row: TSummaryRow) => string); + /** Formatter to be used to render the cell content */ + formatter?: React.ComponentType>; + /** Formatter to be used to render the summary cell content */ + summaryFormatter?: React.ComponentType>; + /** Formatter to be used to render the group cell content */ + groupFormatter?: React.ComponentType>; + /** Enables cell editing. If set and no editor property specified, then a textinput will be used as the cell editor */ + editable?: boolean | ((row: TRow) => boolean); + /** Enable filtering for this column */ + filterable?: boolean; + /** Enable sorting for this column */ + sortable?: boolean; + /** Sets the column sort order to be descending instead of ascending the first time the column is sorted */ + sortDescendingFirst?: boolean; + /** Editor to be rendered when cell of column is being edited. If set, then the column is automatically set to be editable */ + editor?: React.ComponentType; + /** Header renderer for each header cell */ + headerRenderer?: React.ComponentType; + /** Component to be used to filter the data of the column */ + filterRenderer?: React.ComponentType; + /** Whether the column can be resized */ + resizable?: boolean; + /** Whether the column is frozen (fixed position) */ + frozen?: boolean; + } + + export interface SelectRowEvent { + row: TRow; + checked: boolean; + isShiftClick: boolean; + } + + export interface RowSelectionHook { + (): [boolean, (selectRowEvent: SelectRowEvent) => void]; + } + + export const useRowSelection: RowSelectionHook; + + export interface DataGridProps { + /** Grid columns. */ + columns: readonly Column[]; + /** An array of objects representing each row */ + rows: readonly TRow[]; + /** Rows to be pinned at the top of the rows view for summary */ + summaryRows?: readonly TSummaryRow[]; + /** Used to uniquely identify each row */ + rowKeyGetter?: (row: TRow) => React.Key; + onRowsChange?: (rows: TRow[], data: any) => void; + /** Function called whenever row data is updated */ + onRowChange?: (row: TRow, data: any) => void; + /** Called when the grid is scrolled */ + onScroll?: (event: React.UIEvent) => void; + /** Number of rows to render at a time */ + rowHeight?: number; + /** Header row height */ + headerRowHeight?: number; + /** Summary row height */ + summaryRowHeight?: number; + /** Set of selected row keys */ + selectedRows?: ReadonlySet; + /** Function called whenever row selection is changed */ + onSelectedRowsChange?: (selectedRows: Set) => void; + /** Used to specify the height of the grid */ + height?: number; + /** Used to specify the width of the grid */ + width?: number; + /** Toggles whether cells should be autofocused when navigated to via keyboard */ + enableCellAutoFocus?: boolean; + className?: string; + style?: React.CSSProperties; + /** Direction of the grid */ + direction?: 'ltr' | 'rtl'; + } + + const DataGrid: React.FC>; + export default DataGrid; +} \ No newline at end of file