Match columns tweaks

This commit is contained in:
2025-02-27 00:50:31 -05:00
parent ca35a67e9f
commit bb455b3c37
2 changed files with 427 additions and 124 deletions

View File

@@ -19,13 +19,21 @@ import {
import { useQuery } from "@tanstack/react-query"
import config from "@/config"
import { Button } from "@/components/ui/button"
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, FileIcon } from "lucide-react"
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, FileIcon, CheckIcon, ChevronsUpDown } from "lucide-react"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useVirtualizer } from '@tanstack/react-virtual';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { cn } from "@/lib/utils"
export type MatchColumnsProps<T extends string> = {
data: RawData[]
@@ -166,15 +174,12 @@ const MemoizedColumnSamplePreview = React.memo(({ samples }: { samples: any[] })
</Button>
</PopoverTrigger>
<PopoverContent side="right" align="start" className="w-[250px] p-0">
<div className="p-3 border-b">
<h4 className="text-sm font-medium">Sample Data</h4>
</div>
<ScrollArea className="h-[200px] overflow-auto">
<ScrollArea className="h-[200px] overflow-y-auto">
<div className="p-3 space-y-2">
{samples.map((sample, i) => (
<div key={i} className="text-sm">
<span className="text-muted-foreground text-xs mr-2">{i + 1}:</span>
<span className="font-medium">{String(sample || '(empty)')}</span>
{i < samples.length - 1 && <Separator className="w-full my-2" />}
</div>
))}
</div>
@@ -264,6 +269,353 @@ const ValueMappings = memo(({
);
});
// Add these new components before the MatchColumnsStep component
const SupplierSelector = React.memo(({
value,
onChange,
suppliers
}: {
value?: string;
onChange: (value: string) => void;
suppliers: any[]
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{value
? suppliers?.find((supplier: any) =>
supplier.value === value)?.label || "Select supplier..."
: "Select supplier..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search suppliers..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No suppliers found.</CommandEmpty>
<CommandGroup>
{suppliers?.map((supplier: any) => (
<CommandItem
key={supplier.value}
value={supplier.label}
onSelect={() => {
onChange(supplier.value);
setOpen(false); // Close popover after selection
}}
>
{supplier.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === supplier.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
const CompanySelector = React.memo(({
value,
onChange,
companies
}: {
value?: string;
onChange: (value: string) => void;
companies: any[]
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{value
? companies?.find((company: any) =>
company.value === value)?.label || "Select company..."
: "Select company..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search companies..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No companies found.</CommandEmpty>
<CommandGroup>
{companies?.map((company: any) => (
<CommandItem
key={company.value}
value={company.label}
onSelect={() => {
onChange(company.value);
setOpen(false); // Close popover after selection
}}
>
{company.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === company.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
const LineSelector = React.memo(({
value,
onChange,
lines,
disabled
}: {
value?: string;
onChange: (value: string) => void;
lines: any[];
disabled: boolean;
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
disabled={disabled}
>
{value
? lines?.find((line: any) =>
line.value === value)?.label || "Select line..."
: "Select line..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search lines..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No lines found.</CommandEmpty>
<CommandGroup>
{lines?.map((line: any) => (
<CommandItem
key={line.value}
value={line.label}
onSelect={() => {
onChange(line.value);
setOpen(false); // Close popover after selection
}}
>
{line.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === line.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
const SubLineSelector = React.memo(({
value,
onChange,
sublines,
disabled
}: {
value?: string;
onChange: (value: string) => void;
sublines: any[];
disabled: boolean;
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
disabled={disabled}
>
{value
? sublines?.find((subline: any) =>
subline.value === value)?.label || "Select sub line..."
: "Select sub line..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search sub lines..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No sub lines found.</CommandEmpty>
<CommandGroup>
{sublines?.map((subline: any) => (
<CommandItem
key={subline.value}
value={subline.label}
onSelect={() => {
onChange(subline.value);
setOpen(false); // Close popover after selection
}}
>
{subline.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === subline.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
// Add this new component before the MatchColumnsStep component
const FieldSelector = React.memo(({
column,
isUnmapped = false,
fieldCategories,
allFields,
onChange,
isFieldMappedToOtherColumn,
handleCommandListWheel
}: {
column: any;
isUnmapped?: boolean;
fieldCategories: any[];
allFields: any[];
onChange: (value: string) => void;
isFieldMappedToOtherColumn: (fieldKey: string, currentColumnIndex: number) => { isMapped: boolean, columnHeader?: string };
handleCommandListWheel: (e: React.WheelEvent) => void;
}) => {
const [open, setOpen] = useState(false);
// For ignored columns, show a badge
if (column.type === ColumnType.ignored) {
return <Badge variant="outline">Ignored</Badge>;
}
// Get the current value if this is a mapped column
const currentValue = "value" in column ? column.value as string : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{currentValue
? allFields.find(f => f.key === currentValue)?.label || "Select field..."
: "Select field..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search fields..." />
<CommandList className="max-h-[300px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No fields found.</CommandEmpty>
{fieldCategories.map(category => (
<CommandGroup key={category.name} heading={category.name}>
{category.fields.map((field: { key: string; label: string }) => {
const { isMapped, columnHeader } = isFieldMappedToOtherColumn(field.key as string, column.index);
return (
<CommandItem
key={field.key as string}
value={field.key as string}
onSelect={(value: string) => {
onChange(value);
setOpen(false); // Close the popover after selection
}}
className={isMapped ? "opacity-70" : ""}
>
<div className="flex-1 flex items-center justify-between">
<span>{field.label}</span>
{isMapped ? (
<span className="text-xs text-muted-foreground ml-2">
(mapped to "{columnHeader}")
</span>
) : null}
</div>
{currentValue === field.key && (
<CheckIcon className="ml-2 h-4 w-4" />
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
export const MatchColumnsStep = React.memo(<T extends string>({
data,
headerValues,
@@ -278,7 +630,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
([...headerValues] as string[]).map((value, index) => ({ type: ColumnType.empty, index, header: value ?? "" })),
)
const [globalSelections, setGlobalSelections] = useState<GlobalSelections>(initialGlobalSelections || {})
const [showAllColumns, setShowAllColumns] = useState(false)
const [showAllColumns, setShowAllColumns] = useState(true)
const [expandedValueMappings, setExpandedValueMappings] = useState<number[]>([])
// Use debounce for expensive operations
@@ -601,18 +953,14 @@ export const MatchColumnsStep = React.memo(<T extends string>({
return mappings;
}, [columns, fields, isFieldCoveredByGlobalSelections]);
// Available fields for mapping (excluding already mapped fields)
// Available fields for mapping (including already mapped fields)
const availableFields = useMemo(() => {
const fieldsArray = Array.isArray(fields) ? fields : [fields];
const mappedFieldKeys = matchedColumns
.filter(col => "value" in col)
.map(col => (col as any).value);
// Don't filter out mapped fields, only filter global selections
return fieldsArray.filter(field =>
!mappedFieldKeys.includes(field.key) &&
!isFieldCoveredByGlobalSelections(field.key)
);
}, [fields, matchedColumns, isFieldCoveredByGlobalSelections]);
}, [fields, isFieldCoveredByGlobalSelections]);
// All available fields including already mapped ones (for editing mapped columns)
const allFields = useMemo(() => {
@@ -963,48 +1311,48 @@ export const MatchColumnsStep = React.memo(<T extends string>({
return handlers;
}, [columns, onChange]);
// Render the field selector for a column
// Add a function to check if a field is already mapped to another column
const isFieldMappedToOtherColumn = useCallback((fieldKey: string, currentColumnIndex: number) => {
const matchedColumnForField = columns.find(col =>
col.type !== ColumnType.empty &&
col.type !== ColumnType.ignored &&
"value" in col &&
col.value === fieldKey &&
col.index !== currentColumnIndex
);
return matchedColumnForField ? { isMapped: true, columnHeader: matchedColumnForField.header } : { isMapped: false };
}, [columns]);
// Add a wheel handler function for command lists
const handleCommandListWheel = useCallback((e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
}, []);
// Replace the renderFieldSelector function with a more stable version
const renderFieldSelector = useCallback((column: Column<T>, isUnmapped: boolean = false) => {
// For ignored columns, show a badge
if (column.type === ColumnType.ignored) {
return <Badge variant="outline">Ignored</Badge>;
}
// Get the current value if this is a mapped column
const currentValue = "value" in column ? column.value as string : undefined;
// Use all fields for mapped columns, and only available fields for unmapped columns
const fieldCategoriesForSelector = isUnmapped ? availableFieldCategories : allFieldCategories;
// Get the pre-created onChange handler for this column
const handleChange = columnChangeHandlers.get(column.index);
return (
<Select
value={currentValue}
onValueChange={handleChange}
>
<SelectTrigger>
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{fieldCategoriesForSelector.map(category => (
<div key={category.name}>
<div className="px-2 py-1.5 text-sm font-semibold text-muted-foreground">
{category.name}
</div>
{category.fields.map(field => (
<SelectItem key={field.key as string} value={field.key as string}>
{field.label}
</SelectItem>
))}
<Separator className="my-1" />
</div>
))}
</SelectContent>
</Select>
<FieldSelector
column={column}
isUnmapped={isUnmapped}
fieldCategories={availableFieldCategories}
allFields={allFields}
onChange={(value: string) => {
if (handleChange) handleChange(value);
}}
isFieldMappedToOtherColumn={isFieldMappedToOtherColumn}
handleCommandListWheel={handleCommandListWheel}
/>
);
}, [availableFieldCategories, allFieldCategories, columnChangeHandlers]);
}, [availableFieldCategories, allFields, columnChangeHandlers, isFieldMappedToOtherColumn, handleCommandListWheel]);
// Replace the renderValueMappings function with a memoized version
const renderValueMappings = useCallback((column: Column<T>) => {
@@ -1023,8 +1371,8 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<Table>
<TableHeader className="sticky top-0 bg-muted z-10">
<TableRow>
<TableHead className="w-1/3">Spreadsheet Column</TableHead>
<TableHead className="w-12 text-center">Data</TableHead>
<TableHead className="w-1/4">Imported Spreadsheet Column</TableHead>
<TableHead className="w-15 text-center">Sample Data</TableHead>
<TableHead className="w-12"></TableHead>
<TableHead>Map To Field</TableHead>
<TableHead className="w-24 text-right">Ignore</TableHead>
@@ -1216,97 +1564,52 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium">Supplier</label>
<Select
<SupplierSelector
value={globalSelections.supplier}
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, supplier: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select supplier..." />
</SelectTrigger>
<SelectContent>
{hasSuppliers(stableFieldOptions) ?
stableFieldOptions.suppliers.map((supplier: any) => (
<SelectItem key={supplier.value} value={supplier.value}>
{supplier.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
onChange={(value) => setGlobalSelections(prev => ({ ...prev, supplier: value }))}
suppliers={fieldOptions?.suppliers || []}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Company</label>
<Select
<CompanySelector
value={globalSelections.company}
onValueChange={(value) => {
setGlobalSelections(prev => ({
...prev,
company: value,
line: undefined,
subline: undefined
}))
}}
>
<SelectTrigger>
<SelectValue placeholder="Select company..." />
</SelectTrigger>
<SelectContent>
{hasCompanies(stableFieldOptions) ?
stableFieldOptions.companies.map((company: any) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
onChange={(value) => setGlobalSelections(prev => ({
...prev,
company: value,
line: undefined,
subline: undefined
}))}
companies={fieldOptions?.companies || []}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Line</label>
<Select
<LineSelector
value={globalSelections.line}
onValueChange={(value) => {
setGlobalSelections(prev => ({
...prev,
line: value,
subline: undefined
}))
}}
onChange={(value) => setGlobalSelections(prev => ({
...prev,
line: value,
subline: undefined
}))}
lines={productLines || []}
disabled={!globalSelections.company}
>
<SelectTrigger>
<SelectValue placeholder="Select line..." />
</SelectTrigger>
<SelectContent>
{Array.isArray(stableProductLines) ?
stableProductLines.map((line: any) => (
<SelectItem key={line.value} value={line.value}>
{line.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Sub Line</label>
<Select
<SubLineSelector
value={globalSelections.subline}
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, subline: value }))}
onChange={(value) => setGlobalSelections(prev => ({
...prev,
subline: value
}))}
sublines={sublines || []}
disabled={!globalSelections.line}
>
<SelectTrigger>
<SelectValue placeholder="Select sub line..." />
</SelectTrigger>
<SelectContent>
{Array.isArray(stableSublines) ?
stableSublines.map((subline: any) => (
<SelectItem key={subline.value} value={subline.value}>
{subline.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
@@ -1378,8 +1681,8 @@ export const MatchColumnsStep = React.memo(<T extends string>({
size="sm"
onClick={() => setShowAllColumns(!showAllColumns)}
>
{showAllColumns ? <EyeOffIcon className="h-3.5 w-3.5 mr-1" /> : <EyeIcon className="h-3.5 w-3.5 mr-1" />}
{showAllColumns ? "Hide mapped" : "Show all"}
{!showAllColumns ? <EyeIcon className="h-3.5 w-3.5 mr-1" /> : <EyeOffIcon className="h-3.5 w-3.5 mr-1" />}
{!showAllColumns ? "Show all" : "Hide mapped"}
</Button>
</div>
</div>

View File

@@ -48,7 +48,7 @@ export const translations = {
filterSwitchTitle: "Show only rows with errors",
},
imageUploadStep: {
title: "Add Product Images",
title: "Add Images",
nextButtonTitle: "Submit",
backButtonTitle: "Back",
},