From 0a20d74bb64c88afadb1b6901d4948f0fc474a74 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 4 Oct 2025 19:26:31 -0400 Subject: [PATCH] Fix lines/sublines getting stuck in loading state on change --- .../components/ValidationContainer.tsx | 21 ++- .../components/ValidationTable.tsx | 32 ++-- .../hooks/useProductLinesFetching.tsx | 175 +++++++++++++++--- .../hooks/useValidationState.tsx | 7 +- 4 files changed, 194 insertions(+), 41 deletions(-) diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx index 0f38ed2..1a4c034 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx @@ -446,6 +446,13 @@ const ValidationContainer = ({ line: undefined, subline: undefined }; + } else if (rowIndex >= 0 && rowIndex < newData.length) { + // Fallback if __index is not yet present on the row + newData[rowIndex] = { + ...newData[rowIndex], + line: undefined, + subline: undefined + }; } return newData; }); @@ -454,7 +461,9 @@ const ValidationContainer = ({ setValidatingCells(prev => new Set(prev).add(`${rowIndex}-line`)); setTimeout(() => { - fetchProductLines(rowId, companyId) + // Use __index when available, otherwise fall back to the UI row index + const rowKey = (rowId !== undefined && rowId !== null) ? rowId : rowIndex; + fetchProductLines(rowKey, companyId) .catch(err => { console.error(`Error fetching product lines for company ${companyId}:`, err); toast.error("Failed to load product lines"); @@ -480,6 +489,12 @@ const ValidationContainer = ({ ...newData[idx], subline: undefined }; + } else if (rowIndex >= 0 && rowIndex < newData.length) { + // Fallback if __index is not yet present on the row + newData[rowIndex] = { + ...newData[rowIndex], + subline: undefined + }; } else { console.warn(`Could not find row with ID ${rowId} to clear subline values`); } @@ -489,7 +504,9 @@ const ValidationContainer = ({ // Fetch sublines setValidatingCells(prev => new Set(prev).add(`${rowIndex}-subline`)); - fetchSublines(rowId, lineId) + // Use __index when available, otherwise fall back to the UI row index + const rowKey = (rowId !== undefined && rowId !== null) ? rowId : rowIndex; + fetchSublines(rowKey, lineId) .catch(err => { console.error(`Error fetching sublines for line ${lineId}:`, err); toast.error("Failed to load sublines"); diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx index 718e55b..cce156e 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx @@ -342,12 +342,13 @@ const ValidationTable = ({ cell: ({ row }) => { // Get row-specific options for line and subline fields let options = fieldOptions; - const rowId = row.original.__index; + const rowId = (row.original as any).__index; + const lookupKey = (rowId !== undefined && rowId !== null) ? rowId : row.index; - if (fieldKey === 'line' && rowId && rowProductLines[rowId]) { - options = rowProductLines[rowId]; - } else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) { - options = rowSublines[rowId]; + if (fieldKey === 'line' && lookupKey !== undefined && rowProductLines[lookupKey]) { + options = rowProductLines[lookupKey]; + } else if (fieldKey === 'subline' && lookupKey !== undefined && rowSublines[lookupKey]) { + options = rowSublines[lookupKey]; } // Get the current cell value first @@ -374,10 +375,10 @@ const ValidationTable = ({ isLoading = true; } // Add loading state for line/subline fields - else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) { + else if (fieldKey === 'line' && lookupKey !== undefined && isLoadingLines[lookupKey]) { isLoading = true; } - else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) { + else if (fieldKey === 'subline' && lookupKey !== undefined && isLoadingSublines[lookupKey]) { isLoading = true; } } @@ -634,15 +635,22 @@ const ValidationTable = ({ ); }; -// Simplified memo - React 18+ handles most optimizations well -// Only check critical props that frequently change +// Memo comparator: re-render when any prop affecting visible state changes. +// Keep this conservative to avoid skipping updates for loading/options states. const areEqual = (prev: ValidationTableProps, next: ValidationTableProps) => { - // Only prevent re-render if absolutely nothing changed (rare case) return ( + // Core props prev.data === next.data && prev.validationErrors === next.validationErrors && - prev.rowSelection === next.rowSelection + prev.rowSelection === next.rowSelection && + // Loading + validation state that affects cell skeletons + prev.validatingCells === next.validatingCells && + prev.isLoadingLines === next.isLoadingLines && + prev.isLoadingSublines === next.isLoadingSublines && + // Options sources used for line/subline selects + prev.rowProductLines === next.rowProductLines && + prev.rowSublines === next.rowSublines ); }; -export default React.memo(ValidationTable, areEqual); +export default React.memo(ValidationTable, areEqual); diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx index 840471e..4a5822f 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useProductLinesFetching.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import axios from 'axios' import { toast } from 'sonner' @@ -18,24 +18,53 @@ export const useProductLinesFetching = (data: Record[]) => { const [companyLinesCache, setCompanyLinesCache] = useState>({}); const [lineSublineCache, setLineSublineCache] = useState>({}); + // Track in-flight requests to prevent duplicate fetches (especially in StrictMode/dev) + const pendingCompanyRequests = useRef>(new Set()); + const pendingLineRequests = useRef>(new Set()); + // Function to fetch product lines for a specific company - memoized - const fetchProductLines = useCallback(async (rowIndex: string | number, companyId: string) => { + const fetchProductLines = useCallback(async (rowIndex: string | number | undefined | null, companyId: string) => { try { // Only fetch if we have a valid company ID if (!companyId) return; - console.log(`Fetching product lines for row ${rowIndex}, company ${companyId}`); + const logRowKey = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : 'all-matching-rows'; + console.log(`Fetching product lines for row ${logRowKey}, company ${companyId}`); // Check if we already have this company's lines in the cache if (companyLinesCache[companyId]) { console.log(`Using cached product lines for company ${companyId}`); // Use cached data - setRowProductLines(prev => ({ ...prev, [rowIndex]: companyLinesCache[companyId] })); - return companyLinesCache[companyId]; + const cached = companyLinesCache[companyId]; + // Update the specific row if provided + if (rowIndex !== undefined && rowIndex !== null) { + setRowProductLines(prev => ({ ...prev, [rowIndex]: cached })); + } + // Also update all rows that currently have this company set + const updates: Record = {}; + data.forEach((row, idx) => { + if (row.company && String(row.company) === String(companyId)) { + const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx; + updates[key] = cached; + } + }); + if (Object.keys(updates).length > 0) { + setRowProductLines(prev => ({ ...prev, ...updates })); + } + return cached; } + + // If a request for this company is already in flight, skip duplicate fetch + if (pendingCompanyRequests.current.has(companyId)) { + console.log(`Skipping fetch for company ${companyId} - request already pending`); + return; + } + pendingCompanyRequests.current.add(companyId); // Set loading state for this row - setIsLoadingLines(prev => ({ ...prev, [rowIndex]: true })); + if (rowIndex !== undefined && rowIndex !== null) { + setIsLoadingLines(prev => ({ ...prev, [rowIndex]: true })); + } // Fetch product lines from API const productLinesUrl = `/api/import/product-lines/${companyId}`; @@ -53,8 +82,22 @@ export const useProductLinesFetching = (data: Record[]) => { // Store in company cache setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines })); - // Store for this specific row - setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines })); + // Update specific row if provided + if (rowIndex !== undefined && rowIndex !== null) { + setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines })); + } + + // Also update all rows that currently have this company set + const updates: Record = {}; + data.forEach((row, idx) => { + if (row.company && String(row.company) === String(companyId)) { + const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx; + updates[key] = formattedLines; + } + }); + if (Object.keys(updates).length > 0) { + setRowProductLines(prev => ({ ...prev, ...updates })); + } return formattedLines; } catch (error) { @@ -65,33 +108,63 @@ export const useProductLinesFetching = (data: Record[]) => { setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] })); // Store empty array for this specific row - setRowProductLines(prev => ({ ...prev, [rowIndex]: [] })); + if (rowIndex !== undefined && rowIndex !== null) { + setRowProductLines(prev => ({ ...prev, [rowIndex]: [] })); + } return []; } finally { + // Clear pending flag + pendingCompanyRequests.current.delete(companyId); // Clear loading state - setIsLoadingLines(prev => ({ ...prev, [rowIndex]: false })); + if (rowIndex !== undefined && rowIndex !== null) { + setIsLoadingLines(prev => ({ ...prev, [rowIndex]: false })); + } } - }, [companyLinesCache]); + }, [companyLinesCache, data]); // Function to fetch sublines for a specific line - memoized - const fetchSublines = useCallback(async (rowIndex: string | number, lineId: string) => { + const fetchSublines = useCallback(async (rowIndex: string | number | undefined | null, lineId: string) => { try { // Only fetch if we have a valid line ID if (!lineId) return; - console.log(`Fetching sublines for row ${rowIndex}, line ${lineId}`); + const logRowKey = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : 'all-matching-rows'; + console.log(`Fetching sublines for row ${logRowKey}, line ${lineId}`); // Check if we already have this line's sublines in the cache if (lineSublineCache[lineId]) { console.log(`Using cached sublines for line ${lineId}`); // Use cached data - setRowSublines(prev => ({ ...prev, [rowIndex]: lineSublineCache[lineId] })); - return lineSublineCache[lineId]; + const cached = lineSublineCache[lineId]; + if (rowIndex !== undefined && rowIndex !== null) { + setRowSublines(prev => ({ ...prev, [rowIndex]: cached })); + } + // Also update all rows with this line + const updates: Record = {}; + data.forEach((row, idx) => { + if (row.line && String(row.line) === String(lineId)) { + const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx; + updates[key] = cached; + } + }); + if (Object.keys(updates).length > 0) { + setRowSublines(prev => ({ ...prev, ...updates })); + } + return cached; } + + // If a request for this line is already in flight, skip duplicate fetch + if (pendingLineRequests.current.has(lineId)) { + console.log(`Skipping fetch for line ${lineId} - request already pending`); + return; + } + pendingLineRequests.current.add(lineId); // Set loading state for this row - setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: true })); + if (rowIndex !== undefined && rowIndex !== null) { + setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: true })); + } // Fetch sublines from API const sublinesUrl = `/api/import/sublines/${lineId}`; @@ -109,8 +182,22 @@ export const useProductLinesFetching = (data: Record[]) => { // Store in line cache setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines })); - // Store for this specific row - setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines })); + // Update specific row if provided + if (rowIndex !== undefined && rowIndex !== null) { + setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines })); + } + + // Also update all rows with this line + const updates: Record = {}; + data.forEach((row, idx) => { + if (row.line && String(row.line) === String(lineId)) { + const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx; + updates[key] = formattedSublines; + } + }); + if (Object.keys(updates).length > 0) { + setRowSublines(prev => ({ ...prev, ...updates })); + } return formattedSublines; } catch (error) { @@ -120,14 +207,20 @@ export const useProductLinesFetching = (data: Record[]) => { setLineSublineCache(prev => ({ ...prev, [lineId]: [] })); // Store empty array for this specific row - setRowSublines(prev => ({ ...prev, [rowIndex]: [] })); + if (rowIndex !== undefined && rowIndex !== null) { + setRowSublines(prev => ({ ...prev, [rowIndex]: [] })); + } return []; } finally { + // Clear pending flag + pendingLineRequests.current.delete(lineId); // Clear loading state - setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: false })); + if (rowIndex !== undefined && rowIndex !== null) { + setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: false })); + } } - }, [lineSublineCache]); + }, [lineSublineCache, data]); // When data changes, fetch product lines and sublines for rows that have company/line values useEffect(() => { @@ -198,6 +291,11 @@ export const useProductLinesFetching = (data: Record[]) => { // Process companies that need product lines companiesNeeded.forEach((rowIds, companyId) => { + // If this company is already being fetched, skip creating another request + if (pendingCompanyRequests.current.has(companyId)) { + console.log(`Skipping batch fetch for company ${companyId} - request already pending`); + return; + } // Skip if already in cache if (companyLinesCache[companyId]) { console.log(`Using cached product lines for company ${companyId}`); @@ -218,6 +316,8 @@ export const useProductLinesFetching = (data: Record[]) => { // Create fetch promise const fetchPromise = (async () => { + // Mark this company as pending + pendingCompanyRequests.current.add(companyId); // Safety timeout to ensure loading state is cleared after 10 seconds const timeoutId = setTimeout(() => { console.log(`Safety timeout triggered for company ${companyId}`); @@ -253,13 +353,19 @@ export const useProductLinesFetching = (data: Record[]) => { const productLines = response.data; console.log(`Received ${productLines.length} product lines for company ${companyId}`); + // Format the data for dropdown display (consistent with single-row fetch) + const formattedLines = productLines.map((line: any) => ({ + label: line.name || line.label || String(line.value || line.id), + value: String(line.value || line.id) + })); + // Store in company cache - setCompanyLinesCache(prev => ({ ...prev, [companyId]: productLines })); + setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines })); // Update all rows with this company const updates: Record = {}; rowIds.forEach(rowId => { - updates[rowId] = productLines; + updates[rowId] = formattedLines; }); setRowProductLines(prev => ({ ...prev, ...updates })); } catch (error) { @@ -278,6 +384,8 @@ export const useProductLinesFetching = (data: Record[]) => { // Show error toast toast.error(`Failed to load product lines for company ${companyId}`); } finally { + // Clear pending flag for company + pendingCompanyRequests.current.delete(companyId); // Clear the safety timeout clearTimeout(timeoutId); @@ -295,6 +403,11 @@ export const useProductLinesFetching = (data: Record[]) => { // Process lines that need sublines linesNeeded.forEach((rowIds, lineId) => { + // If this line is already being fetched, skip creating another request + if (pendingLineRequests.current.has(lineId)) { + console.log(`Skipping batch fetch for line ${lineId} - request already pending`); + return; + } // Skip if already in cache if (lineSublineCache[lineId]) { console.log(`Using cached sublines for line ${lineId}`); @@ -315,6 +428,8 @@ export const useProductLinesFetching = (data: Record[]) => { // Create fetch promise const fetchPromise = (async () => { + // Mark this line as pending + pendingLineRequests.current.add(lineId); // Safety timeout to ensure loading state is cleared after 10 seconds const timeoutId = setTimeout(() => { console.log(`Safety timeout triggered for line ${lineId}`); @@ -350,13 +465,19 @@ export const useProductLinesFetching = (data: Record[]) => { const sublines = response.data; console.log(`Received ${sublines.length} sublines for line ${lineId}`); + // Format the data for dropdown display (consistent with single-row fetch) + const formattedSublines = sublines.map((subline: any) => ({ + label: subline.name || subline.label || String(subline.value || subline.id), + value: String(subline.value || subline.id) + })); + // Store in line cache - setLineSublineCache(prev => ({ ...prev, [lineId]: sublines })); + setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines })); // Update all rows with this line const updates: Record = {}; rowIds.forEach(rowId => { - updates[rowId] = sublines; + updates[rowId] = formattedSublines; }); setRowSublines(prev => ({ ...prev, ...updates })); } catch (error) { @@ -375,6 +496,8 @@ export const useProductLinesFetching = (data: Record[]) => { // Show error toast toast.error(`Failed to load sublines for line ${lineId}`); } finally { + // Clear pending flag for line + pendingLineRequests.current.delete(lineId); // Clear the safety timeout clearTimeout(timeoutId); @@ -417,4 +540,4 @@ export const useProductLinesFetching = (data: Record[]) => { fetchProductLines, fetchSublines }; -}; \ No newline at end of file +}; diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx index df18a7a..09c0f60 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx @@ -35,9 +35,14 @@ export const useValidationState = ({ // Core data state const [data, setData] = useState[]>(() => { // Clean price fields in initial data before setting state - return initialData.map((row) => { + return initialData.map((row, index) => { const updatedRow = { ...row } as Record; + // Ensure each row has a stable __index key for downstream lookups + if (updatedRow.__index === undefined || updatedRow.__index === null || updatedRow.__index === '') { + updatedRow.__index = String(index); + } + // Clean price fields using utility if (updatedRow.msrp !== undefined) { updatedRow.msrp = cleanPriceField(updatedRow.msrp);