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,
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 = <T extends string>({
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 = <T extends string>({
...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 = <T extends string>({
// 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");

View File

@@ -342,12 +342,13 @@ const ValidationTable = <T extends string>({
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 = <T extends string>({
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 = <T extends string>({
);
};
// 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<any>, next: ValidationTableProps<any>) => {
// 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);

View File

@@ -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<string, any>[]) => {
const [companyLinesCache, setCompanyLinesCache] = 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
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<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
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<string, any>[]) => {
// 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<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;
} catch (error) {
@@ -65,33 +108,63 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
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<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
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<string, any>[]) => {
// 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<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;
} catch (error) {
@@ -120,14 +207,20 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
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<string, any>[]) => {
// 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<string, any>[]) => {
// 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<string, any>[]) => {
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<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = productLines;
updates[rowId] = formattedLines;
});
setRowProductLines(prev => ({ ...prev, ...updates }));
} catch (error) {
@@ -278,6 +384,8 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// 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<string, any>[]) => {
// 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<string, any>[]) => {
// 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<string, any>[]) => {
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<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = sublines;
updates[rowId] = formattedSublines;
});
setRowSublines(prev => ({ ...prev, ...updates }));
} catch (error) {
@@ -375,6 +496,8 @@ export const useProductLinesFetching = (data: Record<string, any>[]) => {
// 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<string, any>[]) => {
fetchProductLines,
fetchSublines
};
};
};

View File

@@ -35,9 +35,14 @@ export const useValidationState = <T extends string>({
// Core data state
const [data, setData] = useState<RowData<T>[]>(() => {
// 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>;
// 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);