Fix lines/sublines getting stuck in loading state on change

This commit is contained in:
2025-10-04 19:26:31 -04:00
parent 9761c29934
commit 0a20d74bb6
4 changed files with 194 additions and 41 deletions

View File

@@ -446,6 +446,13 @@ const ValidationContainer = <T extends string>({
line: undefined, line: undefined,
subline: 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; return newData;
}); });
@@ -454,7 +461,9 @@ const ValidationContainer = <T extends string>({
setValidatingCells(prev => new Set(prev).add(`${rowIndex}-line`)); setValidatingCells(prev => new Set(prev).add(`${rowIndex}-line`));
setTimeout(() => { 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 => { .catch(err => {
console.error(`Error fetching product lines for company ${companyId}:`, err); console.error(`Error fetching product lines for company ${companyId}:`, err);
toast.error("Failed to load product lines"); toast.error("Failed to load product lines");
@@ -480,6 +489,12 @@ const ValidationContainer = <T extends string>({
...newData[idx], ...newData[idx],
subline: undefined 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 { } else {
console.warn(`Could not find row with ID ${rowId} to clear subline values`); console.warn(`Could not find row with ID ${rowId} to clear subline values`);
} }
@@ -489,7 +504,9 @@ const ValidationContainer = <T extends string>({
// Fetch sublines // Fetch sublines
setValidatingCells(prev => new Set(prev).add(`${rowIndex}-subline`)); 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 => { .catch(err => {
console.error(`Error fetching sublines for line ${lineId}:`, err); console.error(`Error fetching sublines for line ${lineId}:`, err);
toast.error("Failed to load sublines"); toast.error("Failed to load sublines");

View File

@@ -342,12 +342,13 @@ const ValidationTable = <T extends string>({
cell: ({ row }) => { cell: ({ row }) => {
// Get row-specific options for line and subline fields // Get row-specific options for line and subline fields
let options = fieldOptions; 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]) { if (fieldKey === 'line' && lookupKey !== undefined && rowProductLines[lookupKey]) {
options = rowProductLines[rowId]; options = rowProductLines[lookupKey];
} else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) { } else if (fieldKey === 'subline' && lookupKey !== undefined && rowSublines[lookupKey]) {
options = rowSublines[rowId]; options = rowSublines[lookupKey];
} }
// Get the current cell value first // Get the current cell value first
@@ -374,10 +375,10 @@ const ValidationTable = <T extends string>({
isLoading = true; isLoading = true;
} }
// Add loading state for line/subline fields // Add loading state for line/subline fields
else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) { else if (fieldKey === 'line' && lookupKey !== undefined && isLoadingLines[lookupKey]) {
isLoading = true; isLoading = true;
} }
else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) { else if (fieldKey === 'subline' && lookupKey !== undefined && isLoadingSublines[lookupKey]) {
isLoading = true; isLoading = true;
} }
} }
@@ -634,14 +635,21 @@ const ValidationTable = <T extends string>({
); );
}; };
// Simplified memo - React 18+ handles most optimizations well // Memo comparator: re-render when any prop affecting visible state changes.
// Only check critical props that frequently change // Keep this conservative to avoid skipping updates for loading/options states.
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => { const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
// Only prevent re-render if absolutely nothing changed (rare case)
return ( return (
// Core props
prev.data === next.data && prev.data === next.data &&
prev.validationErrors === next.validationErrors && 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
); );
}; };

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import axios from 'axios' import axios from 'axios'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -18,24 +18,53 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
const [companyLinesCache, setCompanyLinesCache] = useState<Record<string, any[]>>({}); const [companyLinesCache, setCompanyLinesCache] = useState<Record<string, any[]>>({});
const [lineSublineCache, setLineSublineCache] = useState<Record<string, any[]>>({}); const [lineSublineCache, setLineSublineCache] = useState<Record<string, any[]>>({});
// Track in-flight requests to prevent duplicate fetches (especially in StrictMode/dev)
const pendingCompanyRequests = useRef<Set<string>>(new Set());
const pendingLineRequests = useRef<Set<string>>(new Set());
// Function to fetch product lines for a specific company - memoized // 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 { try {
// Only fetch if we have a valid company ID // Only fetch if we have a valid company ID
if (!companyId) return; 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 // Check if we already have this company's lines in the cache
if (companyLinesCache[companyId]) { if (companyLinesCache[companyId]) {
console.log(`Using cached product lines for company ${companyId}`); console.log(`Using cached product lines for company ${companyId}`);
// Use cached data // Use cached data
setRowProductLines(prev => ({ ...prev, [rowIndex]: companyLinesCache[companyId] })); const cached = companyLinesCache[companyId];
return 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<string, any[]> = {};
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 // 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 // Fetch product lines from API
const productLinesUrl = `/api/import/product-lines/${companyId}`; const productLinesUrl = `/api/import/product-lines/${companyId}`;
@@ -53,8 +82,22 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Store in company cache // Store in company cache
setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines })); setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
// Store for this specific row // Update specific row if provided
setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines })); if (rowIndex !== undefined && rowIndex !== null) {
setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines }));
}
// Also update all rows that currently have this company set
const updates: Record<string, any[]> = {};
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; return formattedLines;
} catch (error) { } catch (error) {
@@ -65,33 +108,63 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] })); setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
// Store empty array for this specific row // Store empty array for this specific row
setRowProductLines(prev => ({ ...prev, [rowIndex]: [] })); if (rowIndex !== undefined && rowIndex !== null) {
setRowProductLines(prev => ({ ...prev, [rowIndex]: [] }));
}
return []; return [];
} finally { } finally {
// Clear pending flag
pendingCompanyRequests.current.delete(companyId);
// Clear loading state // 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 // 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 { try {
// Only fetch if we have a valid line ID // Only fetch if we have a valid line ID
if (!lineId) return; 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 // Check if we already have this line's sublines in the cache
if (lineSublineCache[lineId]) { if (lineSublineCache[lineId]) {
console.log(`Using cached sublines for line ${lineId}`); console.log(`Using cached sublines for line ${lineId}`);
// Use cached data // Use cached data
setRowSublines(prev => ({ ...prev, [rowIndex]: lineSublineCache[lineId] })); const cached = lineSublineCache[lineId];
return lineSublineCache[lineId]; if (rowIndex !== undefined && rowIndex !== null) {
setRowSublines(prev => ({ ...prev, [rowIndex]: cached }));
}
// Also update all rows with this line
const updates: Record<string, any[]> = {};
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 // 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 // Fetch sublines from API
const sublinesUrl = `/api/import/sublines/${lineId}`; const sublinesUrl = `/api/import/sublines/${lineId}`;
@@ -109,8 +182,22 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Store in line cache // Store in line cache
setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines })); setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
// Store for this specific row // Update specific row if provided
setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines })); if (rowIndex !== undefined && rowIndex !== null) {
setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines }));
}
// Also update all rows with this line
const updates: Record<string, any[]> = {};
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; return formattedSublines;
} catch (error) { } catch (error) {
@@ -120,14 +207,20 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
setLineSublineCache(prev => ({ ...prev, [lineId]: [] })); setLineSublineCache(prev => ({ ...prev, [lineId]: [] }));
// Store empty array for this specific row // Store empty array for this specific row
setRowSublines(prev => ({ ...prev, [rowIndex]: [] })); if (rowIndex !== undefined && rowIndex !== null) {
setRowSublines(prev => ({ ...prev, [rowIndex]: [] }));
}
return []; return [];
} finally { } finally {
// Clear pending flag
pendingLineRequests.current.delete(lineId);
// Clear loading state // 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 // When data changes, fetch product lines and sublines for rows that have company/line values
useEffect(() => { useEffect(() => {
@@ -198,6 +291,11 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Process companies that need product lines // Process companies that need product lines
companiesNeeded.forEach((rowIds, companyId) => { 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 // Skip if already in cache
if (companyLinesCache[companyId]) { if (companyLinesCache[companyId]) {
console.log(`Using cached product lines for company ${companyId}`); console.log(`Using cached product lines for company ${companyId}`);
@@ -218,6 +316,8 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Create fetch promise // Create fetch promise
const fetchPromise = (async () => { const fetchPromise = (async () => {
// Mark this company as pending
pendingCompanyRequests.current.add(companyId);
// Safety timeout to ensure loading state is cleared after 10 seconds // Safety timeout to ensure loading state is cleared after 10 seconds
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.log(`Safety timeout triggered for company ${companyId}`); console.log(`Safety timeout triggered for company ${companyId}`);
@@ -253,13 +353,19 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
const productLines = response.data; const productLines = response.data;
console.log(`Received ${productLines.length} product lines for company ${companyId}`); 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 // Store in company cache
setCompanyLinesCache(prev => ({ ...prev, [companyId]: productLines })); setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
// Update all rows with this company // Update all rows with this company
const updates: Record<string, any[]> = {}; const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => { rowIds.forEach(rowId => {
updates[rowId] = productLines; updates[rowId] = formattedLines;
}); });
setRowProductLines(prev => ({ ...prev, ...updates })); setRowProductLines(prev => ({ ...prev, ...updates }));
} catch (error) { } catch (error) {
@@ -278,6 +384,8 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Show error toast // Show error toast
toast.error(`Failed to load product lines for company ${companyId}`); toast.error(`Failed to load product lines for company ${companyId}`);
} finally { } finally {
// Clear pending flag for company
pendingCompanyRequests.current.delete(companyId);
// Clear the safety timeout // Clear the safety timeout
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -295,6 +403,11 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Process lines that need sublines // Process lines that need sublines
linesNeeded.forEach((rowIds, lineId) => { 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 // Skip if already in cache
if (lineSublineCache[lineId]) { if (lineSublineCache[lineId]) {
console.log(`Using cached sublines for line ${lineId}`); console.log(`Using cached sublines for line ${lineId}`);
@@ -315,6 +428,8 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Create fetch promise // Create fetch promise
const fetchPromise = (async () => { const fetchPromise = (async () => {
// Mark this line as pending
pendingLineRequests.current.add(lineId);
// Safety timeout to ensure loading state is cleared after 10 seconds // Safety timeout to ensure loading state is cleared after 10 seconds
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.log(`Safety timeout triggered for line ${lineId}`); console.log(`Safety timeout triggered for line ${lineId}`);
@@ -350,13 +465,19 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
const sublines = response.data; const sublines = response.data;
console.log(`Received ${sublines.length} sublines for line ${lineId}`); 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 // Store in line cache
setLineSublineCache(prev => ({ ...prev, [lineId]: sublines })); setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
// Update all rows with this line // Update all rows with this line
const updates: Record<string, any[]> = {}; const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => { rowIds.forEach(rowId => {
updates[rowId] = sublines; updates[rowId] = formattedSublines;
}); });
setRowSublines(prev => ({ ...prev, ...updates })); setRowSublines(prev => ({ ...prev, ...updates }));
} catch (error) { } catch (error) {
@@ -375,6 +496,8 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// Show error toast // Show error toast
toast.error(`Failed to load sublines for line ${lineId}`); toast.error(`Failed to load sublines for line ${lineId}`);
} finally { } finally {
// Clear pending flag for line
pendingLineRequests.current.delete(lineId);
// Clear the safety timeout // Clear the safety timeout
clearTimeout(timeoutId); clearTimeout(timeoutId);

View File

@@ -35,9 +35,14 @@ export const useValidationState = <T extends string>({
// Core data state // Core data state
const [data, setData] = useState<RowData<T>[]>(() => { const [data, setData] = useState<RowData<T>[]>(() => {
// Clean price fields in initial data before setting state // Clean price fields in initial data before setting state
return initialData.map((row) => { return initialData.map((row, index) => {
const updatedRow = { ...row } as Record<string, any>; const updatedRow = { ...row } as Record<string, any>;
// 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 // Clean price fields using utility
if (updatedRow.msrp !== undefined) { if (updatedRow.msrp !== undefined) {
updatedRow.msrp = cleanPriceField(updatedRow.msrp); updatedRow.msrp = cleanPriceField(updatedRow.msrp);