Add global options to pass in to validate step, move remove empty/duplicate button to select header row step

This commit is contained in:
2025-02-24 15:03:32 -05:00
parent 441a2c74ad
commit 6bf93d33ea
7 changed files with 754 additions and 266 deletions

View File

@@ -24,12 +24,31 @@ import {
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
import { DeepReadonly as TsDeepReadonly } from "ts-essentials"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useQuery } from "@tanstack/react-query"
import config from "@/config"
import { Button } from "@/components/ui/button"
export type MatchColumnsProps<T extends string> = {
data: RawData[]
headerValues: RawData
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>) => void
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections) => void
onBack?: () => void
initialGlobalSelections?: GlobalSelections
}
export type GlobalSelections = {
supplier?: string
company?: string
line?: string
subline?: string
}
export enum ColumnType {
@@ -98,6 +117,7 @@ export const MatchColumnsStep = <T extends string>({
headerValues,
onContinue,
onBack,
initialGlobalSelections
}: MatchColumnsProps<T>) => {
const dataExample = data.slice(0, 2)
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations, allowInvalidSubmit } = useRsi<T>()
@@ -107,6 +127,54 @@ export const MatchColumnsStep = <T extends string>({
([...headerValues] as string[]).map((value, index) => ({ type: ColumnType.empty, index, header: value ?? "" })),
)
const [showUnmatchedFieldsAlert, setShowUnmatchedFieldsAlert] = useState(false)
const [globalSelections, setGlobalSelections] = useState<GlobalSelections>(initialGlobalSelections || {})
// Initialize with any provided global selections
useEffect(() => {
if (initialGlobalSelections) {
setGlobalSelections(initialGlobalSelections)
}
}, [initialGlobalSelections])
// Fetch field options from the API
const { data: fieldOptions } = useQuery({
queryKey: ["import-field-options"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error("Failed to fetch field options");
}
return response.json();
},
});
// Fetch product lines when company is selected
const { data: productLines } = useQuery({
queryKey: ["product-lines", globalSelections.company],
queryFn: async () => {
if (!globalSelections.company) return [];
const response = await fetch(`${config.apiUrl}/import/product-lines/${globalSelections.company}`);
if (!response.ok) {
throw new Error("Failed to fetch product lines");
}
return response.json();
},
enabled: !!globalSelections.company,
});
// Fetch sublines when line is selected
const { data: sublines } = useQuery({
queryKey: ["sublines", globalSelections.line],
queryFn: async () => {
if (!globalSelections.line) return [];
const response = await fetch(`${config.apiUrl}/import/sublines/${globalSelections.line}`);
if (!response.ok) {
throw new Error("Failed to fetch sublines");
}
return response.json();
},
enabled: !!globalSelections.line,
});
const onChange = useCallback(
(value: T, columnIndex: number) => {
@@ -185,17 +253,19 @@ export const MatchColumnsStep = <T extends string>({
setShowUnmatchedFieldsAlert(true)
} else {
setIsLoading(true)
await onContinue(normalizeTableData(columns, data, fields), data, columns)
// Normalize the data with global selections before continuing
const normalizedData = normalizeTableData(columns, data, fields)
await onContinue(normalizedData, data, columns, globalSelections)
setIsLoading(false)
}
}, [unmatchedRequiredFields.length, onContinue, columns, data, fields])
}, [unmatchedRequiredFields.length, onContinue, columns, data, fields, globalSelections])
const handleAlertOnContinue = useCallback(async () => {
setShowUnmatchedFieldsAlert(false)
setIsLoading(true)
await onContinue(normalizeTableData(columns, data, fields), data, columns)
await onContinue(normalizeTableData(columns, data, fields), data, columns, globalSelections)
setIsLoading(false)
}, [onContinue, columns, data, fields])
}, [onContinue, columns, data, fields, globalSelections])
useEffect(
() => {
@@ -242,21 +312,150 @@ export const MatchColumnsStep = <T extends string>({
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
<ColumnGrid
columns={columns}
onContinue={handleOnContinue}
onBack={onBack}
isLoading={isLoading}
userColumn={(column) => (
<UserTableColumn
column={column}
onIgnore={onIgnore}
onRevertIgnore={onRevertIgnore}
entries={dataExample.map((row) => row[column.index])}
/>
)}
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
/>
<div className="flex h-[calc(100vh-10rem)] flex-col">
<div className="flex-1 overflow-hidden">
<div className="h-full flex flex-col">
<div className="px-8 py-6 flex-1 overflow-auto">
<div className="space-y-8">
{/* Global Selections Section */}
<Card>
<CardHeader>
<CardTitle>Global Selections</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Supplier</label>
<Select
value={globalSelections.supplier}
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, supplier: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select supplier..." />
</SelectTrigger>
<SelectContent>
{fieldOptions?.suppliers?.map((supplier: any) => (
<SelectItem key={supplier.value} value={supplier.value}>
{supplier.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Company</label>
<Select
value={globalSelections.company}
onValueChange={(value) => {
setGlobalSelections(prev => ({
...prev,
company: value,
line: undefined,
subline: undefined
}))
}}
>
<SelectTrigger>
<SelectValue placeholder="Select company..." />
</SelectTrigger>
<SelectContent>
{fieldOptions?.companies?.map((company: any) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Line</label>
<Select
value={globalSelections.line}
onValueChange={(value) => {
setGlobalSelections(prev => ({
...prev,
line: value,
subline: undefined
}))
}}
disabled={!globalSelections.company}
>
<SelectTrigger>
<SelectValue placeholder="Select line..." />
</SelectTrigger>
<SelectContent>
{productLines?.map((line: any) => (
<SelectItem key={line.value} value={line.value}>
{line.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Sub Line</label>
<Select
value={globalSelections.subline}
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, subline: value }))}
disabled={!globalSelections.line}
>
<SelectTrigger>
<SelectValue placeholder="Select sub line..." />
</SelectTrigger>
<SelectContent>
{sublines?.map((subline: any) => (
<SelectItem key={subline.value} value={subline.value}>
{subline.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="flex-1">
<ColumnGrid
columns={columns}
onContinue={handleOnContinue}
onBack={onBack}
isLoading={isLoading}
userColumn={(column) => (
<UserTableColumn
column={column}
onIgnore={onIgnore}
onRevertIgnore={onRevertIgnore}
entries={dataExample.map((row) => row[column.index])}
/>
)}
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
/>
</div>
</div>
</div>
</div>
</div>
<div className="border-t bg-muted px-8 py-4">
<div className="flex items-center justify-between">
{onBack && (
<Button variant="outline" onClick={onBack}>
{translations.matchColumnsStep.backButtonTitle}
</Button>
)}
<Button
className="ml-auto"
disabled={isLoading}
onClick={handleOnContinue}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
</div>
</div>
</div>
</>
)
}

View File

@@ -2,7 +2,6 @@ import type React from "react"
import type { Column, Columns } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import { useRsi } from "../../../hooks/useRsi"
import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
type ColumnGridProps<T extends string> = {
@@ -18,9 +17,6 @@ export const ColumnGrid = <T extends string>({
columns,
userColumn,
templateColumn,
onContinue,
onBack,
isLoading,
}: ColumnGridProps<T>) => {
const { translations } = useRsi()
const normalColumnWidth = 250
@@ -32,81 +28,61 @@ export const ColumnGrid = <T extends string>({
)
return (
<div className="flex h-[calc(100vh-10rem)] flex-col">
<div className="flex-1 overflow-hidden">
<div className="px-8 py-6">
<div className="mb-8">
<h2 className="text-3xl font-semibold text-foreground">
{translations.matchColumnsStep.title}
</h2>
</div>
<ScrollArea className="relative">
<div className="space-y-8" style={{ width: totalWidth }}>
{/* Your table section */}
<div>
<h3 className="mb-4 text-lg font-medium text-foreground">
{translations.matchColumnsStep.userTableTitle}
</h3>
<div className="relative">
<div
className="grid auto-cols-fr gap-4"
style={{
gridTemplateColumns: columns.map(col =>
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
).join(" "),
}}
>
{columns.map((column, index) => (
<div key={column.header + index}>
{userColumn(column)}
</div>
))}
<div className="h-full">
<div className="mb-8">
<h2 className="text-3xl font-semibold text-foreground">
{translations.matchColumnsStep.title}
</h2>
</div>
<ScrollArea className="relative" type="hover">
<div className="space-y-8" style={{ width: totalWidth }}>
{/* Your table section */}
<div>
<h3 className="mb-4 text-lg font-medium text-foreground">
{translations.matchColumnsStep.userTableTitle}
</h3>
<div className="relative">
<div
className="grid auto-cols-fr gap-4"
style={{
gridTemplateColumns: columns.map(col =>
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
).join(" "),
}}
>
{columns.map((column, index) => (
<div key={column.header + index}>
{userColumn(column)}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/50 to-background" />
</div>
</div>
{/* Will become section */}
<div>
<h3 className="mb-4 text-lg font-medium text-foreground">
{translations.matchColumnsStep.templateTitle}
</h3>
<div
className="grid auto-cols-fr gap-4"
style={{
gridTemplateColumns: columns.map(col =>
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
).join(" "),
}}
>
{columns.map((column, index) => (
<div key={column.header + index}>
{templateColumn(column)}
</div>
))}
</div>
))}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/50 to-background" />
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
{/* Will become section */}
<div>
<h3 className="mb-4 text-lg font-medium text-foreground">
{translations.matchColumnsStep.templateTitle}
</h3>
<div
className="grid auto-cols-fr gap-4"
style={{
gridTemplateColumns: columns.map(col =>
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
).join(" "),
}}
>
{columns.map((column, index) => (
<div key={column.header + index}>
{templateColumn(column)}
</div>
))}
</div>
</div>
</div>
</div>
<div className="border-t bg-muted px-8 py-4 -mb-1">
<div className="flex items-center justify-between">
{onBack && (
<Button variant="outline" onClick={onBack}>
{translations.matchColumnsStep.backButtonTitle}
</Button>
)}
<Button
className="ml-auto"
disabled={isLoading}
onClick={() => onContinue([])}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
}

View File

@@ -3,6 +3,7 @@ import { SelectHeaderTable } from "./components/SelectHeaderTable"
import { useRsi } from "../../hooks/useRsi"
import type { RawData } from "../../types"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
type SelectHeaderProps = {
data: RawData[]
@@ -12,17 +13,142 @@ type SelectHeaderProps = {
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
const { translations } = useRsi()
const { toast } = useToast()
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
const [isLoading, setIsLoading] = useState(false)
const [localData, setLocalData] = useState<RawData[]>(data)
const handleContinue = useCallback(async () => {
const [selectedRowIndex] = selectedRows
// We consider data above header to be redundant
const trimmedData = data.slice(selectedRowIndex + 1)
const trimmedData = localData.slice(selectedRowIndex + 1)
setIsLoading(true)
await onContinue(data[selectedRowIndex], trimmedData)
await onContinue(localData[selectedRowIndex], trimmedData)
setIsLoading(false)
}, [onContinue, data, selectedRows])
}, [onContinue, localData, selectedRows])
const discardEmptyAndDuplicateRows = useCallback(() => {
// Helper function to count non-empty values in a row
const countNonEmptyValues = (values: Record<string, any>): number => {
return Object.values(values).filter(val =>
val !== undefined &&
val !== null &&
(typeof val === 'string' ? val.trim() !== '' : true)
).length;
};
// Helper function to normalize row values for case-insensitive comparison
const normalizeRowForComparison = (row: Record<string, any>): Record<string, any> => {
return Object.entries(row).reduce((acc, [key, value]) => {
// Convert string values to lowercase for case-insensitive comparison
if (typeof value === 'string') {
acc[key.toLowerCase()] = value.toLowerCase().trim();
} else {
acc[key.toLowerCase()] = value;
}
return acc;
}, {} as Record<string, any>);
};
// First, analyze all rows to determine if we have rows with multiple values
const rowsWithValues = localData.map(row => {
return countNonEmptyValues(row);
});
// Check if we have any rows with more than one value
const hasMultiValueRows = rowsWithValues.some(count => count > 1);
// Get the selected header row
const [selectedRowIndex] = selectedRows;
const selectedHeaderRow = localData[selectedRowIndex];
// Debug: Log the selected header row
console.log("Selected header row:", selectedHeaderRow);
const normalizedHeaderRow = normalizeRowForComparison(selectedHeaderRow);
// Debug: Log the normalized header row
console.log("Normalized header row:", normalizedHeaderRow);
const selectedHeaderStr = JSON.stringify(Object.entries(normalizedHeaderRow).sort());
// Filter out empty rows, rows with single values (if we have multi-value rows),
// and duplicate rows (including duplicates of the header row)
const seen = new Set<string>();
// Add the selected header row to the seen set first
seen.add(selectedHeaderStr);
// Debug: Track which rows are being removed and why
const removedRows: { index: number; reason: string; row: any }[] = [];
const filteredRows = localData.filter((row, index) => {
// Always keep the selected header row
if (index === selectedRowIndex) {
return true;
}
// Check if it's empty or has only one value
const nonEmptyCount = rowsWithValues[index];
if (nonEmptyCount === 0 || (hasMultiValueRows && nonEmptyCount <= 1)) {
removedRows.push({ index, reason: "Empty or single value", row });
return false;
}
// Check if it's a duplicate (case-insensitive)
const normalizedRow = normalizeRowForComparison(row);
// Debug: If this row might be a duplicate of the header, log it
if (index < 5 || index === selectedRowIndex + 1 || index === selectedRowIndex - 1) {
console.log(`Row ${index} normalized:`, normalizedRow);
}
const rowStr = JSON.stringify(Object.entries(normalizedRow).sort());
if (seen.has(rowStr)) {
removedRows.push({
index,
reason: "Duplicate",
row,
normalizedRow,
rowStr,
headerStr: selectedHeaderStr
});
return false;
}
seen.add(rowStr);
return true;
});
// Debug: Log removed rows
console.log("Removed rows:", removedRows);
// Only update if we actually removed any rows
if (filteredRows.length < localData.length) {
// Adjust the selected row index if needed
const newSelectedIndex = filteredRows.findIndex(row =>
JSON.stringify(Object.entries(normalizeRowForComparison(row)).sort()) === selectedHeaderStr
);
// Debug: Log the new selected index
console.log("New selected index:", newSelectedIndex);
setLocalData(filteredRows);
setSelectedRows(new Set([newSelectedIndex]));
toast({
title: "Rows removed",
description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`,
variant: "default"
});
} else {
toast({
title: "No rows removed",
description: "No empty, single-value, or duplicate rows were found",
variant: "default"
});
}
}, [localData, selectedRows, toast]);
return (
<div className="flex flex-col">
@@ -32,8 +158,17 @@ export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps
</h2>
</div>
<div className="flex-1 px-8 mb-12 overflow-auto">
<div className="flex justify-end mb-4">
<Button
variant="outline"
size="sm"
onClick={discardEmptyAndDuplicateRows}
>
Remove Empty/Duplicates
</Button>
</div>
<SelectHeaderTable
data={data}
data={localData}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
/>

View File

@@ -6,7 +6,7 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStep } from "./ValidationStep/ValidationStep"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData } from "../types"
@@ -20,6 +20,7 @@ export enum StepType {
matchColumns = "matchColumns",
validateData = "validateData",
}
export type StepState =
| {
type: StepType.upload
@@ -36,10 +37,12 @@ export type StepState =
type: StepType.matchColumns
data: RawData[]
headerValues: RawData
globalSelections?: GlobalSelections
}
| {
type: StepType.validateData
data: any[]
globalSelections?: GlobalSelections
}
interface Props {
@@ -72,6 +75,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
[toast, translations],
)
// Keep track of global selections across steps
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
state.type === StepType.validateData || state.type === StepType.matchColumns
? state.globalSelections
: undefined
)
switch (state.type) {
case StepType.upload:
return (
@@ -132,6 +142,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
type: StepType.matchColumns,
data,
headerValues,
globalSelections: persistedGlobalSelections,
})
} catch (e) {
errorToast((e as Error).message)
@@ -145,13 +156,16 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
<MatchColumnsStep
data={state.data}
headerValues={state.headerValues}
onContinue={async (values, rawData, columns) => {
initialGlobalSelections={persistedGlobalSelections}
onContinue={async (values, rawData, columns, globalSelections) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
setPersistedGlobalSelections(globalSelections)
onNext({
type: StepType.validateData,
data: dataWithMeta,
globalSelections,
})
} catch (e) {
errorToast((e as Error).message)
@@ -161,7 +175,20 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
/>
)
case StepType.validateData:
return <ValidationStep initialData={state.data} file={uploadedFile!} onBack={onBack} />
return (
<ValidationStep
initialData={state.data}
file={uploadedFile!}
onBack={() => {
if (onBack) {
// When going back, preserve the global selections
setPersistedGlobalSelections(state.globalSelections)
onBack()
}
}}
globalSelections={state.globalSelections}
/>
)
default:
return <Progress value={33} className="w-full" />
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState, useEffect, memo } from "react"
import { useRsi } from "../../hooks/useRsi"
import type { Meta, Error } from "./types"
import { addErrorsAndRunHooks } from "./utils/dataMutations"
import type { Data, SelectOption, Result } from "../../types"
import type { Data, SelectOption, Result, Fields, Field } from "../../types"
import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
@@ -74,6 +74,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { GlobalSelections } from "../MatchColumnsStep/MatchColumnsStep"
import { useQuery } from "@tanstack/react-query"
// Template interface
interface Template {
@@ -103,8 +105,10 @@ type Props<T extends string> = {
initialData: RowData<T>[]
file: File
onBack?: () => void
globalSelections?: GlobalSelections
}
// Remove the local Field type declaration since we're importing it
type BaseFieldType = {
multiline?: boolean;
price?: boolean;
@@ -121,7 +125,7 @@ type MultiInputFieldType = BaseFieldType & {
type SelectFieldType = {
type: "select" | "multi-select";
options: SelectOption[];
options: readonly SelectOption[];
}
type CheckboxFieldType = {
@@ -131,23 +135,14 @@ type CheckboxFieldType = {
type FieldType = InputFieldType | MultiInputFieldType | SelectFieldType | CheckboxFieldType;
type Field<T extends string> = {
label: string;
key: T;
description?: string;
alternateMatches?: string[];
validations?: ({ rule: string } & Record<string, any>)[];
fieldType: FieldType;
width?: number;
disabled?: boolean;
onChange?: (value: string) => void;
}
type CellProps = {
value: any;
onChange: (value: any) => void;
error?: { level: string; message: string };
field: Field<string>;
productLines?: SelectOption[];
sublines?: SelectOption[];
}
// Define ValidationIcon before EditableCell
@@ -167,12 +162,39 @@ const ValidationIcon = memo(({ error }: { error: { level: string, message: strin
))
// Wrap EditableCell with memo to avoid unnecessary re-renders
const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
const EditableCell = memo(({ value, onChange, error, field, productLines, sublines }: CellProps) => {
const [isEditing, setIsEditing] = useState(false)
const [inputValue, setInputValue] = useState(value ?? "")
const [searchQuery, setSearchQuery] = useState("")
const [localValues, setLocalValues] = useState<string[]>([])
// Determine if the field should be disabled based on its key and context
const isFieldDisabled = useMemo(() => {
if (field.key === 'line') {
// Enable the line field if we have product lines available
return !productLines || productLines.length === 0;
}
if (field.key === 'subline') {
// Enable subline field if we have sublines available
return !sublines || sublines.length === 0;
}
return field.disabled;
}, [field.key, field.disabled, productLines, sublines]);
// For debugging
useEffect(() => {
if (field.key === 'subline') {
console.log('Subline field state:', {
disabled: field.disabled,
isFieldDisabled,
value,
options: field.fieldType.type === 'select' ? field.fieldType.options : [],
sublines,
hasSublines: sublines && sublines.length > 0
});
}
}, [field, value, sublines, isFieldDisabled]);
const handleWheel = useCallback((e: React.WheelEvent) => {
const commandList = e.currentTarget;
commandList.scrollTop += e.deltaY;
@@ -245,21 +267,33 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
if (fieldType.type === "select" || fieldType.type === "multi-select") {
if (fieldType.type === "select") {
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
// For line and subline fields, ensure we're using the latest options
if (field.key === 'line' && productLines?.length) {
const option = productLines.find((opt: SelectOption) => opt.value === value);
return option?.label || value;
}
if (field.key === 'subline' && sublines?.length) {
const option = sublines.find((opt: SelectOption) => opt.value === value);
return option?.label || value;
}
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value;
}
if (Array.isArray(value)) {
return value.map(v => fieldType.options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ")
const options = field.key === 'line' && productLines?.length ? productLines :
field.key === 'subline' && sublines?.length ? sublines :
fieldType.options;
return value.map(v => options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ");
}
return value
return value;
}
if (fieldType.type === "checkbox") {
if (typeof value === "boolean") return value ? "Yes" : "No"
return value
if (typeof value === "boolean") return value ? "Yes" : "No";
return value;
}
if (fieldType.type === "multi-input" && Array.isArray(value)) {
return value.join(", ")
return value.join(", ");
}
return value
return value;
}
const validateAndCommit = (newValue: string | boolean) => {
@@ -329,7 +363,9 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options
{(field.key === 'line' && productLines ? productLines :
field.key === 'subline' && sublines ? sublines :
field.fieldType.options)
.filter(option =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
@@ -412,7 +448,9 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options
{(field.key === 'line' && productLines ? productLines :
field.key === 'subline' && sublines ? sublines :
field.fieldType.options)
.filter(option =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
@@ -578,7 +616,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
<div
id={`cell-${field.key}`}
onClick={(e) => {
if (field.fieldType.type !== "checkbox" && !field.disabled) {
if (field.fieldType.type !== "checkbox" && !isFieldDisabled) {
e.stopPropagation()
setIsEditing(true)
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
@@ -590,7 +628,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
field.fieldType.multiline && "max-h-[100px] overflow-y-auto",
currentError ? "border-destructive" : "border-input",
field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
field.disabled && "opacity-50 cursor-not-allowed bg-muted"
isFieldDisabled && "opacity-50 cursor-not-allowed bg-muted"
)}
>
{((field.fieldType.type === "input" || field.fieldType.type === "multi-input") && field.fieldType.multiline) ? (
@@ -620,7 +658,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
</div>
)}
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} />
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", isFieldDisabled ? "opacity-30" : "opacity-50")} />
)}
{currentError && <ValidationIcon error={currentError} />}
</div>
@@ -1013,11 +1051,152 @@ const SaveTemplateDialog = memo(({
export const ValidationStep = <T extends string>({
initialData,
file,
onBack}: Props<T>) => {
onBack,
globalSelections
}: Props<T>) => {
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>();
const { toast } = useToast();
const [data, setData] = useState<(Data<T> & ExtendedMeta)[]>(initialData)
// Fetch product lines when company is selected
const { data: productLines } = useQuery({
queryKey: ["product-lines", globalSelections?.company],
queryFn: async () => {
if (!globalSelections?.company) return [];
console.log('Fetching product lines for company:', globalSelections.company);
const response = await fetch(`${config.apiUrl}/import/product-lines/${globalSelections.company}`);
if (!response.ok) {
console.error('Failed to fetch product lines:', response.status, response.statusText);
throw new Error("Failed to fetch product lines");
}
const data = await response.json();
console.log('Received product lines:', data);
return data;
},
enabled: !!globalSelections?.company,
staleTime: 30000, // Cache for 30 seconds
});
// Fetch sublines when line is selected
const { data: sublines } = useQuery({
queryKey: ["sublines", globalSelections?.line],
queryFn: async () => {
if (!globalSelections?.line) return [];
const response = await fetch(`${config.apiUrl}/import/sublines/${globalSelections.line}`);
if (!response.ok) {
throw new Error("Failed to fetch sublines");
}
return response.json();
},
enabled: !!globalSelections?.line,
});
// Apply global selections to initial data and validate
const initialDataWithGlobals = useMemo(() => {
if (!globalSelections) return initialData;
// Find the field definitions for our global selection fields
const supplierField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'supplier');
const companyField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'company');
const lineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'line');
const sublineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'subline');
// Helper function to safely set a field value and update options if needed
const setFieldValue = (field: Field<T> | undefined, value: string | undefined, options?: SelectOption[]) => {
if (!field || !value) return undefined;
if (field.fieldType.type === 'select') {
// Use provided options if available, otherwise use field's default options
const fieldOptions = options || (field.fieldType as SelectFieldType).options;
// First try to find by value (ID)
const optionByValue = fieldOptions.find(opt => opt.value === value);
if (optionByValue) {
return optionByValue.value;
}
// Then try to find by label (name)
const optionByLabel = fieldOptions.find(opt => opt.label.toLowerCase() === value.toLowerCase());
if (optionByLabel) {
return optionByLabel.value;
}
}
return value;
};
// Apply global selections to each row
const newData = initialData.map(row => {
const newRow = { ...row };
// Apply each global selection if it exists
if (globalSelections.supplier) {
const supplierValue = setFieldValue(supplierField as Field<T>, globalSelections.supplier);
if (supplierValue) newRow.supplier = supplierValue;
}
if (globalSelections.company) {
const companyValue = setFieldValue(companyField as Field<T>, globalSelections.company);
if (companyValue) newRow.company = companyValue;
}
if (globalSelections.line && productLines) {
const lineValue = setFieldValue(lineField as Field<T>, globalSelections.line, productLines);
if (lineValue) newRow.line = lineValue;
}
if (globalSelections.subline && sublines) {
const sublineValue = setFieldValue(sublineField as Field<T>, globalSelections.subline, sublines);
if (sublineValue) newRow.subline = sublineValue;
}
return newRow;
});
return newData;
}, [initialData, globalSelections, fields, productLines, sublines]);
// Update field options with fetched data
const fieldsWithUpdatedOptions = useMemo(() => {
return Array.from(fields as ReadonlyFields<T>).map(field => {
if (field.key === 'line') {
return {
...field,
fieldType: {
...field.fieldType,
options: productLines || (field.fieldType.type === 'select' ? field.fieldType.options : []),
},
disabled: (!productLines || productLines.length === 0) && !globalSelections?.line
} as Field<T>;
}
if (field.key === 'subline') {
return {
...field,
fieldType: {
...field.fieldType,
options: sublines || (field.fieldType.type === 'select' ? field.fieldType.options : []),
},
// Enable subline field if we have a global line selection or if we have sublines available
disabled: !globalSelections?.line && (!sublines || sublines.length === 0)
} as Field<T>;
}
return field;
});
}, [fields, productLines, sublines, globalSelections?.line]);
const [data, setData] = useState<RowData<T>[]>(initialDataWithGlobals);
// Run validation when component mounts or when global selections change
useEffect(() => {
const validateData = async () => {
// Cast the fields to the expected type for validation
const validationFields = fieldsWithUpdatedOptions as unknown as Fields<T>;
const validatedData = await addErrorsAndRunHooks(
initialDataWithGlobals,
validationFields,
rowHook,
tableHook
);
setData(validatedData as RowData<T>[]);
};
validateData();
}, [initialDataWithGlobals, fieldsWithUpdatedOptions, rowHook, tableHook]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filterByErrors, setFilterByErrors] = useState(false)
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
@@ -1211,6 +1390,8 @@ export const ValidationStep = <T extends string>({
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
error={error}
field={field as Field<string>}
productLines={productLines}
sublines={sublines}
/>
)
},
@@ -1222,7 +1403,7 @@ export const ValidationStep = <T extends string>({
})))
]
return baseColumns
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate])
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate, productLines, sublines])
const table = useReactTable({
data: filteredData,
@@ -1244,70 +1425,17 @@ export const ValidationStep = <T extends string>({
}
}, [rowSelection, data, updateData]);
const discardEmptyAndDuplicateRows = useCallback(() => {
// Helper function to count non-empty values in a row
const countNonEmptyValues = (values: Record<string, any>): number => {
return Object.values(values).filter(val =>
val !== undefined &&
val !== null &&
(typeof val === 'string' ? val.trim() !== '' : true)
).length;
};
// First, analyze all rows to determine if we have rows with multiple values
const rowsWithValues = data.map(row => {
const { __index, __errors, ...values } = row;
return countNonEmptyValues(values);
});
// Check if we have any rows with more than one value
const hasMultiValueRows = rowsWithValues.some(count => count > 1);
// Filter out empty rows and rows with single values (if we have multi-value rows)
const nonEmptyRows = data.filter((_row, index) => {
const nonEmptyCount = rowsWithValues[index];
// Keep the row if:
// 1. It has more than one value, OR
// 2. It has exactly one value AND we don't have any rows with multiple values
return nonEmptyCount > 0 && (!hasMultiValueRows || nonEmptyCount > 1);
});
// Then, remove duplicates by creating a unique string representation of each row
const seen = new Set<string>();
const uniqueRows = nonEmptyRows.filter(row => {
const { __index, __errors, ...values } = row;
const rowStr = JSON.stringify(Object.entries(values).sort());
if (seen.has(rowStr)) {
return false;
}
seen.add(rowStr);
return true;
});
// Only update if we actually removed any rows
if (uniqueRows.length < data.length) {
updateData(uniqueRows);
setRowSelection({});
toast({
title: "Rows removed",
description: `Removed ${data.length - uniqueRows.length} empty, single-value, or duplicate rows`,
variant: "default"
});
}
}, [data, updateData, toast]);
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>) => {
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>): string | undefined => {
if (field.fieldType.type === "checkbox") {
if (typeof value === "boolean") return value
if (typeof value === "boolean") return value ? "true" : "false"
if (typeof value === "string") {
const normalizedValue = value.toLowerCase().trim()
if (field.fieldType.booleanMatches) {
return !!field.fieldType.booleanMatches[normalizedValue]
return !!field.fieldType.booleanMatches[normalizedValue] ? "true" : "false"
}
return ["yes", "true", "1"].includes(normalizedValue)
return ["yes", "true", "1"].includes(normalizedValue) ? "true" : "false"
}
return false
return "false"
}
if (field.fieldType.type === "select" && field.fieldType.options) {
// Ensure the value matches one of the options
@@ -1320,61 +1448,85 @@ export const ValidationStep = <T extends string>({
)
return matchByLabel ? matchByLabel.value : value
}
return value
return value?.toString()
}, [])
const submitData = useCallback(async () => {
const calculatedData: Result<T> = data.reduce(
(acc, value) => {
const { __index, __errors, __template, ...values } = value
const result: Result<T> = {
validData: [],
invalidData: [],
all: data
};
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key)
if (field) {
obj[key as keyof Data<T>] = normalizeValue(val, field as Field<T>)
} else {
obj[key as keyof Data<T>] = val as string | boolean | undefined
data.forEach((value) => {
const { __index, __errors, __template, ...values } = value;
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key);
if (field) {
const normalizedVal = normalizeValue(val, field as Field<T>);
if (normalizedVal !== undefined) {
obj[key as keyof Data<T>] = normalizedVal as Data<T>[keyof Data<T>];
}
return obj
}, {} as Data<T>)
} else if (val !== undefined) {
obj[key as keyof Data<T>] = String(val) as Data<T>[keyof Data<T>];
}
return obj;
}, {} as Data<T>);
if (__errors) {
for (const key in __errors) {
if (__errors[key].level === "error") {
acc.invalidData.push(normalizedValues)
return acc
}
// Apply global selections with proper normalization
if (globalSelections) {
const supplierField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'supplier');
const companyField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'company');
const lineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'line');
const sublineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'subline');
const supplier = normalizeValue(normalizedValues.supplier, supplierField as Field<T>);
const company = normalizeValue(normalizedValues.company, companyField as Field<T>);
const line = normalizeValue(normalizedValues.line, lineField as Field<T>);
const subline = normalizeValue(normalizedValues.subline, sublineField as Field<T>);
if (supplier) normalizedValues.supplier = supplier;
if (company) normalizedValues.company = company;
if (line) normalizedValues.line = line;
if (subline) normalizedValues.subline = subline;
}
if (__errors) {
for (const key in __errors) {
if (__errors[key].level === "error") {
result.invalidData.push(normalizedValues);
return;
}
}
acc.validData.push(normalizedValues)
return acc
},
{ validData: [] as Data<T>[], invalidData: [] as Data<T>[], all: data },
)
setShowSubmitAlert(false)
setSubmitting(true)
const response = onSubmit(calculatedData, file)
}
result.validData.push(normalizedValues);
});
setShowSubmitAlert(false);
setSubmitting(true);
const response = onSubmit(result, file);
if (response?.then) {
response
.then(() => {
onClose()
onClose();
})
.catch((err: Error) => {
const defaultMessage = translations.alerts.submitError.defaultMessage
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred'
const defaultMessage = translations.alerts.submitError.defaultMessage;
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred';
toast({
variant: "destructive",
title: translations.alerts.submitError.title,
description: String(err?.message || errorMessage),
})
});
})
.finally(() => {
setSubmitting(false)
})
setSubmitting(false);
});
} else {
onClose()
onClose();
}
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations]);
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations, globalSelections]);
const onContinue = useCallback(() => {
const invalidData = data.find((value) => {
@@ -1743,25 +1895,25 @@ export const ValidationStep = <T extends string>({
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => {
const selectedRows = Object.keys(rowSelection);
if (selectedRows.length !== 1) {
toast({
title: "Invalid selection",
description: "Please select exactly one row to save as a template",
variant: "destructive",
});
return;
}
setShowSaveTemplateDialog(true);
}}
>
Save Selected as Template
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
const selectedRows = Object.keys(rowSelection);
if (selectedRows.length !== 1) {
toast({
title: "Invalid selection",
description: "Please select exactly one row to save as a template",
variant: "destructive",
});
return;
}
setShowSaveTemplateDialog(true);
}}
>
Save Selected as Template
</Button>
<Button
variant="outline"
size="sm"
@@ -1770,13 +1922,6 @@ export const ValidationStep = <T extends string>({
>
{translations.validationStep.discardButtonTitle}
</Button>
<Button
variant="outline"
size="sm"
onClick={discardEmptyAndDuplicateRows}
>
Remove Empty/Duplicates
</Button>
<Button
variant="secondary"
size="sm"

View File

@@ -11,6 +11,8 @@ export type RsiProps<T extends string> = {
onClose: () => void
// Field description for requested data
fields: Fields<T>
// Initial state of component that will be rendered on load
initialStepState?: StepState
// Runs after file upload step, receives and returns raw sheet data
uploadStepHook?: (data: RawData[]) => Promise<RawData[]>
// Runs after header selection step, receives and returns raw sheet data
@@ -37,23 +39,28 @@ export type RsiProps<T extends string> = {
maxFileSize?: number
// Automatically map imported headers to specified fields if possible. Default: true
autoMapHeaders?: boolean
// When field type is "select", automatically match values if possible. Default: false
// Automatically map select values to options if possible. Default: false
autoMapSelectValues?: boolean
// Headers matching accuracy: 1 for strict and up for more flexible matching
// Maximum distance for fuzzy string matching. Default: 2
autoMapDistance?: number
// Initial Step state to be rendered on load
initialStepState?: StepState
// Sets SheetJS dateNF option. If date parsing is applied, date will be formatted e.g. "yyyy-mm-dd hh:mm:ss", "m/d/yy h:mm", 'mmm-yy', etc.
// Date format for parsing dates. Default: "yyyy-mm-dd"
dateFormat?: string
// Sets SheetJS "raw" option. If true, parsing will only be applied to xlsx date fields.
// Whether to parse raw values. Default: true
parseRaw?: boolean
// Use for right-to-left (RTL) support
rtl?: boolean
}
export type RawData = Array<string | undefined>
export type RawData = (string | undefined)[]
export type Data<T extends string> = { [key in T]: string | boolean | undefined }
export type Data<T extends string> = {
[key in T]?: string | boolean | undefined
} & {
supplier?: string
company?: string
line?: string
subline?: string
}
// Data model RSI uses for spreadsheet imports
export type Fields<T extends string> = DeepReadonly<Field<T>[]>
@@ -142,13 +149,11 @@ export type RegexValidation = {
export type RowHook<T extends string> = (
row: Data<T>,
addError: (fieldKey: T, error: Info) => void,
table: Data<T>[],
) => Data<T> | Promise<Data<T>>
export type TableHook<T extends string> = (
table: Data<T>[],
addError: (rowIndex: number, fieldKey: T, error: Info) => void,
) => Data<T>[] | Promise<Data<T>[]>
rowIndex: number,
allRows: Data<T>[],
) => Promise<Meta> | Meta
export type TableHook<T extends string> = (rows: Data<T>[]) => Promise<Meta[]> | Meta[]
export type ErrorLevel = "info" | "warning" | "error"
@@ -175,5 +180,5 @@ export type InfoWithSource = Info & {
export type Result<T extends string> = {
validData: Data<T>[]
invalidData: Data<T>[]
all: (Data<T> & Meta)[]
all: Data<T>[]
}

View File

@@ -597,6 +597,7 @@ export function Import() {
}}
onSubmit={handleData}
fields={importFields}
isNavigationEnabled={true}
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
/>
</motion.div>