Fix row highlighting, header alignment, make header sticky

This commit is contained in:
2025-03-11 21:08:02 -04:00
parent 1aee18a025
commit f55d35e301
5 changed files with 219 additions and 213 deletions

View File

@@ -249,7 +249,7 @@ const ItemNumberCell = React.memo(({
);
return (
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px` }}>
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px`, maxWidth: `${width}px`, boxSizing: 'border-box' }}>
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
{isValidating ? (
<div className="flex items-center justify-center gap-2">
@@ -365,7 +365,7 @@ const ValidationCell = ({
// Check for price field
return (
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px` }}>
<TableCell className="p-1 group relative" style={{ width: `${width}px`, minWidth: `${width}px`, maxWidth: `${width}px`, boxSizing: 'border-box' }}>
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
{isValidating ? (
<div className="flex items-center justify-center gap-2">

View File

@@ -14,6 +14,7 @@ import { Fields } from '../../../types'
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
import { TemplateForm } from '@/components/templates/TemplateForm'
import axios from 'axios'
import { RowSelectionState } from '@tanstack/react-table'
/**
* ValidationContainer component - the main wrapper for the validation step
@@ -56,13 +57,15 @@ const ValidationContainer = <T extends string>({
loadTemplates,
setData,
fields,
isLoadingTemplates,
copyDown } = validationState
isLoadingTemplates } = validationState
// Add state for tracking product lines and sublines per row
const [rowProductLines, setRowProductLines] = useState<Record<string, any[]>>({});
const [rowSublines, setRowSublines] = useState<Record<string, any[]>>({});
// These variables are used in the fetchProductLines and fetchSublines functions
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isLoadingLines, setIsLoadingLines] = useState<Record<string, boolean>>({});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isLoadingSublines, setIsLoadingSublines] = useState<Record<string, boolean>>({});
// Add UPC validation state
@@ -432,7 +435,10 @@ const ValidationContainer = <T extends string>({
if (rowData && rowData.__index) {
// Use setTimeout to make this non-blocking
setTimeout(async () => {
await fetchProductLines(rowData.__index, value.toString());
// Ensure value is not undefined before calling toString()
if (value !== undefined) {
await fetchProductLines(rowData.__index as string, value.toString());
}
}, 0);
}
}
@@ -494,7 +500,10 @@ const ValidationContainer = <T extends string>({
if (rowData && rowData.__index) {
// Use setTimeout to make this non-blocking
setTimeout(async () => {
await fetchSublines(rowData.__index, value.toString());
// Ensure value is not undefined before calling toString()
if (value !== undefined) {
await fetchSublines(rowData.__index as string, value.toString());
}
}, 0);
}
}
@@ -711,7 +720,7 @@ const ValidationContainer = <T extends string>({
// Log if we can find a match for our supplier
if (templateData.supplier !== undefined) {
// Need to compare numeric values since supplier options have numeric values
const supplierMatch = options.suppliers.find(s =>
const supplierMatch = options.suppliers.find((s: { value: string | number }) =>
s.value === templateData.supplier ||
Number(s.value) === Number(templateData.supplier)
);
@@ -814,9 +823,8 @@ const ValidationContainer = <T extends string>({
}, [data, rowSelection, setData, setRowSelection]);
// Memoize handlers
const handleFiltersChange = useCallback((newFilters: any) => {
updateFilters(newFilters);
}, [updateFilters]);
// This function is defined for potential future use but not currently used
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleRowSelectionChange = useCallback((newSelection: RowSelectionState) => {
setRowSelection(newSelection);
@@ -883,10 +891,10 @@ const ValidationContainer = <T extends string>({
const renderValidationTable = useMemo(() => (
<EnhancedValidationTable
data={filteredData}
fields={fields}
fields={fields as unknown as Fields<string>}
rowSelection={rowSelection}
setRowSelection={handleRowSelectionChange}
updateRow={handleUpdateRow}
setRowSelection={handleRowSelectionChange as React.Dispatch<React.SetStateAction<RowSelectionState>>}
updateRow={handleUpdateRow as (rowIndex: number, key: string, value: any) => void}
validationErrors={validationErrors}
isValidatingUpc={isRowValidatingUpc}
validatingUpcRows={Array.from(validatingUpcRows)}
@@ -898,6 +906,7 @@ const ValidationContainer = <T extends string>({
itemNumbers={new Map()}
isLoadingTemplates={isLoadingTemplates}
copyDown={handleCopyDown}
upcValidationResults={new Map()}
/>
), [
EnhancedValidationTable,
@@ -923,10 +932,11 @@ const ValidationContainer = <T extends string>({
const isScrolling = useRef(false);
// Memoize scroll handlers
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
if (!isScrolling.current) {
isScrolling.current = true;
const target = event.currentTarget;
// Use type assertion to handle both React.UIEvent and native Event
const target = event.currentTarget as HTMLDivElement;
lastScrollPosition.current = {
left: target.scrollLeft,
top: target.scrollTop
@@ -941,8 +951,13 @@ const ValidationContainer = <T extends string>({
useEffect(() => {
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
// Convert React event handler to native event handler
const nativeHandler = ((evt: Event) => {
handleScroll(evt);
}) as EventListener;
container.addEventListener('scroll', nativeHandler, { passive: true });
return () => container.removeEventListener('scroll', nativeHandler);
}
}, [handleScroll]);
@@ -1031,13 +1046,14 @@ const ValidationContainer = <T extends string>({
style={{
willChange: 'transform',
position: 'relative',
WebkitOverflowScrolling: 'touch' // Improve scroll performance on Safari
WebkitOverflowScrolling: 'touch', // Improve scroll performance on Safari
overscrollBehavior: 'contain', // Prevent scroll chaining
contain: 'paint', // Improve performance for sticky elements
scrollbarWidth: 'thin' // Thinner scrollbars in Firefox
}}
onScroll={handleScroll}
>
<div className="min-w-max"> {/* Force container to be at least as wide as content */}
{renderValidationTable}
</div>
{renderValidationTable}
</div>
</div>
</div>

View File

@@ -217,7 +217,7 @@ const ValidationTable = <T extends string>({
const rowIndex = data.findIndex(r => r === row.original);
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px' }}>
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px' }}>
<MemoizedTemplateSelect
templates={templates}
value={templateValue || ''}
@@ -280,9 +280,9 @@ const ValidationTable = <T extends string>({
size: fieldWidth,
cell: ({ row }) => (
<MemoizedCell
field={field}
value={row.original[field.key]}
onChange={(value) => handleFieldUpdate(row.index, field.key, value)}
field={field as Field<string>}
value={row.original[field.key as keyof typeof row.original]}
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
errors={validationErrors.get(row.index)?.[fieldKey] || []}
isValidating={validatingCells.has(`${row.index}-${field.key}`)}
fieldKey={fieldKey}
@@ -290,7 +290,7 @@ const ValidationTable = <T extends string>({
itemNumber={itemNumbers.get(row.index)}
width={fieldWidth}
rowIndex={row.index}
copyDown={() => handleCopyDown(row.index, field.key)}
copyDown={() => handleCopyDown(row.index, field.key as string)}
/>
)
};
@@ -333,53 +333,71 @@ const ValidationTable = <T extends string>({
}
return (
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed' }}>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
{table.getFlatHeaders().map((header) => (
<TableHead
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`,
maxWidth: `${header.getSize()}px`,
position: 'sticky',
top: 0,
backgroundColor: 'inherit',
zIndex: 1
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
validationErrors.get(data.indexOf(row.original)) ? "bg-red-50/40" : ""
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: `${cell.column.getSize()}px`,
minWidth: `${cell.column.getSize()}px`,
maxWidth: `${cell.column.getSize()}px`
}}
<div className="min-w-max">
<div className="relative">
{/* Custom Table Header - Always Visible */}
<div
className="sticky top-0 z-20 bg-muted border-b shadow-sm"
style={{ width: `${totalWidth}px` }}
>
<div className="flex">
{table.getFlatHeaders().map((header, index) => {
const width = header.getSize();
return (
<div
key={header.id}
className="py-2 px-2 font-bold text-sm text-muted-foreground bg-muted flex items-center justify-center"
style={{
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box',
height: '40px'
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</div>
);
})}
</div>
</div>
{/* Table Body */}
<Table style={{ width: `${totalWidth}px`, tableLayout: 'fixed', borderCollapse: 'separate', borderSpacing: 0, marginTop: '-1px' }}>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "bg-muted/50" : "",
validationErrors.get(data.indexOf(row.original)) &&
Object.keys(validationErrors.get(data.indexOf(row.original)) || {}).length > 0 ? "bg-red-50/40" : ""
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
{row.getVisibleCells().map((cell, cellIndex) => {
const width = cell.column.getSize();
return (
<TableCell
key={cell.id}
style={{
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box',
padding: '0'
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableBody>
</Table>
</div>
</div>
);
};

View File

@@ -30,7 +30,6 @@ interface MultiInputCellProps<T extends string> {
}
// Add global CSS to ensure fixed width constraints - use !important to override other styles
const fixedWidthClass = "!w-full !min-w-0 !max-w-full !flex-shrink-1 !flex-grow-0";
// Memoized option item to prevent unnecessary renders for large option lists
const OptionItem = React.memo(({
@@ -372,133 +371,91 @@ const MultiInputCell = <T extends string>({
// If we have a multi-select field with options, use command UI
if (field.fieldType.type === 'multi-select' && selectOptions.length > 0) {
// Get width from field if available, or default to a reasonable value
const cellWidth = field.width || 200;
// Create a reference to the container element
const containerRef = useRef<HTMLDivElement>(null);
// Create a key-value map for inline styles with fixed width - simplified
const fixedWidth = useMemo(() => ({
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box' as const,
}), [cellWidth]);
// Use layout effect more efficiently - only for the button element
// since the container already uses inline styles
useLayoutEffect(() => {
// Skip if no width specified
if (!cellWidth) return;
// Cache previous width to avoid unnecessary DOM updates
const prevWidth = containerRef.current?.getAttribute('data-prev-width');
// Only update if width changed
if (prevWidth !== String(cellWidth) && containerRef.current) {
// Store new width for next comparison
containerRef.current.setAttribute('data-prev-width', String(cellWidth));
// Only manipulate the button element directly since we can't
// reliably style it with CSS in all cases
const button = containerRef.current.querySelector('button');
if (button) {
const htmlButton = button as HTMLElement;
htmlButton.style.width = `${cellWidth}px`;
htmlButton.style.minWidth = `${cellWidth}px`;
htmlButton.style.maxWidth = `${cellWidth}px`;
}
}
}, [cellWidth]);
return (
<div
ref={containerRef}
className="inline-block fixed-width-cell overflow-visible"
style={fixedWidth}
data-width={cellWidth}
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
handleOpenChange(isOpen);
}}
>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between font-normal",
!internalValue.length && "text-muted-foreground",
hasErrors && "border-red-500",
"h-auto min-h-9 py-1"
)}
onClick={() => setOpen(true)}
style={fixedWidth}
>
<div className="flex items-center w-full justify-between">
<div
className="flex items-center gap-2 overflow-hidden"
style={{
maxWidth: `${cellWidth - 32}px`,
}}
>
{internalValue.length === 0 ? (
<span className="text-muted-foreground truncate w-full">Select...</span>
) : internalValue.length === 1 ? (
<span className="truncate w-full">{selectedValues[0].label}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{internalValue.length} selected
</Badge>
<span className="truncate" style={{ maxWidth: `${cellWidth - 100}px` }}>
{selectedValues.map(v => v.label).join(', ')}
</span>
</>
)}
</div>
<div className="ml-1 flex-none" style={{ width: '20px' }}>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={fixedWidth}
align="start"
sideOffset={4}
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
"border",
!internalValue.length && "text-muted-foreground",
hasErrors ? "border-destructive" : ""
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
if (!open && onStartEdit) onStartEdit();
}}
>
<Command shouldFilter={false} className="overflow-hidden">
<CommandInput
placeholder="Search options..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList
className="overflow-hidden"
ref={commandListRef}
onWheel={handleWheel}
>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{sortedOptions.length > 0 ? (
<VirtualizedOptions
options={sortedOptions}
selectedValues={selectedValueSet}
onSelect={handleSelect}
maxHeight={200}
/>
) : (
<div className="py-6 text-center text-sm">No options match your search</div>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
<div className="flex items-center w-full justify-between">
<div className="flex items-center gap-2 overflow-hidden">
{internalValue.length === 0 ? (
<span className="text-muted-foreground truncate w-full">Select...</span>
) : internalValue.length === 1 ? (
<span className="truncate w-full">{selectedValues[0].label}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{internalValue.length} selected
</Badge>
<span className="truncate">
{selectedValues.map(v => v.label).join(', ')}
</span>
</>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sortedOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{selectedValueSet.has(option.value) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// For standard multi-input without options, use text input
@@ -510,12 +467,6 @@ const MultiInputCell = <T extends string>({
const containerRef = useRef<HTMLDivElement>(null);
// Create a key-value map for inline styles with fixed width - simplified
const fixedWidth = useMemo(() => ({
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box' as const,
}), [cellWidth]);
// Use layout effect more efficiently - only for the button element
// since the container already uses inline styles
@@ -539,6 +490,7 @@ const MultiInputCell = <T extends string>({
htmlButton.style.width = `${cellWidth}px`;
htmlButton.style.minWidth = `${cellWidth}px`;
htmlButton.style.maxWidth = `${cellWidth}px`;
htmlButton.style.boxSizing = 'border-box';
}
}
}, [cellWidth]);
@@ -547,7 +499,12 @@ const MultiInputCell = <T extends string>({
<div
ref={containerRef}
className="inline-block fixed-width-cell"
style={fixedWidth}
style={{
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box',
}}
data-width={cellWidth}
>
{isMultiline ? (
@@ -562,7 +519,12 @@ const MultiInputCell = <T extends string>({
outlineClass,
hasErrors ? "border-destructive" : ""
)}
style={fixedWidth}
style={{
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box',
}}
/>
) : (
<div
@@ -571,10 +533,16 @@ const MultiInputCell = <T extends string>({
"cursor-text truncate",
outlineClass,
hasErrors ? "border-destructive" : "",
"overflow-hidden items-center"
"overflow-hidden items-center",
"w-full"
)}
onClick={handleFocus}
style={fixedWidth}
style={{
width: `${cellWidth}px`,
minWidth: `${cellWidth}px`,
maxWidth: `${cellWidth}px`,
boxSizing: 'border-box',
}}
>
{internalValue.length > 0 ? getDisplayValues().join(`, `) : (
<span className="text-muted-foreground truncate">
@@ -589,7 +557,7 @@ const MultiInputCell = <T extends string>({
// Fallback to default behavior if no width is specified
return (
<div className="w-full">
<div className="w-full overflow-hidden" style={{ boxSizing: 'border-box' }}>
{isMultiline ? (
<Textarea
value={internalValue.join(separator)}
@@ -598,10 +566,11 @@ const MultiInputCell = <T extends string>({
onBlur={handleBlur}
placeholder={`Enter values separated by ${separator}`}
className={cn(
"min-h-[80px] resize-none",
"min-h-[80px] resize-none w-full",
outlineClass,
hasErrors ? "border-destructive" : ""
)}
style={{ boxSizing: 'border-box' }}
/>
) : (
<div
@@ -613,9 +582,10 @@ const MultiInputCell = <T extends string>({
"overflow-hidden items-center"
)}
onClick={handleFocus}
style={{ boxSizing: 'border-box' }}
>
{internalValue.length > 0 ? getDisplayValues().join(`, `) : (
<span className="text-muted-foreground">
<span className="text-muted-foreground truncate w-full">
{`Enter values separated by ${separator}`}
</span>
)}