Fix up validation step to show proper components

This commit is contained in:
2025-02-19 11:37:09 -05:00
parent ed62f03ba0
commit fe70b56d24
2 changed files with 350 additions and 86 deletions

View File

@@ -2,8 +2,7 @@ import { useCallback, useMemo, useState } from "react"
import { useRsi } from "../../hooks/useRsi" import { useRsi } from "../../hooks/useRsi"
import type { Meta } from "./types" import type { Meta } from "./types"
import { addErrorsAndRunHooks } from "./utils/dataMutations" import { addErrorsAndRunHooks } from "./utils/dataMutations"
import { generateColumns } from "./components/columns" import type { Data, Field, SelectOption } from "../../types"
import type { Data } from "../../types"
import { import {
Table, Table,
TableBody, TableBody,
@@ -36,6 +35,13 @@ import {
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
type Props<T extends string> = { type Props<T extends string> = {
initialData: (Data<T> & Meta)[] initialData: (Data<T> & Meta)[]
@@ -43,49 +49,125 @@ type Props<T extends string> = {
onBack?: () => void onBack?: () => void
} }
const EditableCell = ({ value, onChange, error }: { value: string, onChange: (value: string) => void, error?: { level: string } }) => { type CellProps = {
const [isEditing, setIsEditing] = useState(false) value: any,
const [inputValue, setInputValue] = useState(value || "") onChange: (value: any) => void,
error?: { level: string, message: string },
field: Field<string>
}
// Always show input for error cells const EditableCell = ({ value, onChange, error, field }: CellProps) => {
if (error?.level === "error" || isEditing) { const [isEditing, setIsEditing] = useState(false)
return ( const [inputValue, setInputValue] = useState(value ?? "")
<Input
value={inputValue} const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
onChange={(e) => setInputValue(e.target.value)} if (fieldType.type === "select") {
onKeyDown={(e) => { return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
if (e.key === "Enter") { }
onChange(inputValue) if (fieldType.type === "checkbox") {
if (!error?.level) { if (typeof value === "boolean") return value ? "Yes" : "No"
setIsEditing(false) return value
} }
} return value
}}
onBlur={() => {
onChange(inputValue)
if (!error?.level) {
setIsEditing(false)
}
}}
className={`w-full bg-transparent ${
error?.level === "error"
? "border-destructive text-destructive"
: ""
}`}
autoFocus={!error?.level}
/>
)
} }
const isRequiredAndEmpty = field.validations?.some(v => v.rule === "required") && !value
// Show editing UI for:
// 1. Error cells
// 2. When actively editing
// 3. Required select fields that are empty
// 4. Checkbox fields (always show the checkbox)
const shouldShowEditUI = error?.level === "error" ||
isEditing ||
(field.fieldType.type === "select" && isRequiredAndEmpty) ||
field.fieldType.type === "checkbox"
if (shouldShowEditUI) {
switch (field.fieldType.type) {
case "select":
return (
<Select
defaultOpen={isEditing}
value={value as string || ""}
onValueChange={(newValue) => {
onChange(newValue)
setIsEditing(false)
}}
>
<SelectTrigger
className={`w-full ${
(error?.level === "error" || isRequiredAndEmpty)
? "border-destructive text-destructive"
: ""
}`}
>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{field.fieldType.options.map((option: SelectOption) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
case "checkbox":
return (
<div className="flex items-center gap-2">
<Checkbox
checked={Boolean(value)}
onCheckedChange={(checked) => {
onChange(checked)
}}
/>
</div>
)
default:
return (
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onChange(inputValue)
if (!error?.level) {
setIsEditing(false)
}
}
}}
onBlur={() => {
onChange(inputValue)
if (!error?.level) {
setIsEditing(false)
}
}}
className={`w-full bg-transparent ${
error?.level === "error"
? "border-destructive text-destructive"
: ""
}`}
autoFocus={!error?.level}
/>
)
}
}
// Display mode
return ( return (
<div <div
onClick={() => { onClick={() => {
setIsEditing(true) if (field.fieldType.type !== "checkbox") {
setInputValue(value || "") setIsEditing(true)
setInputValue(value ?? "")
}
}} }}
className="cursor-text py-2" className={`cursor-text py-2 ${
error?.level === "error" ? "text-destructive" : ""
}`}
> >
{value || ""} {getDisplayValue(value, field.fieldType)}
</div> </div>
) )
} }
@@ -158,12 +240,13 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 50,
}, },
...generateColumns(fields).map((col): ColumnDef<Data<T> & Meta> => ({ ...fields.map((field: Field<T>): ColumnDef<Data<T> & Meta> => ({
accessorKey: col.key, accessorKey: field.key,
header: col.name, header: field.label,
cell: ({ row, column }) => { cell: ({ row, column }) => {
const value = row.getValue(column.id) as string const value = row.getValue(column.id)
const error = row.original.__errors?.[column.id] const error = row.original.__errors?.[column.id]
const rowIndex = row.index const rowIndex = row.index
@@ -172,9 +255,16 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
value={value} value={value}
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)} onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
error={error} error={error}
field={field}
/> />
) )
}, },
// Use configured width or fallback to sensible defaults
size: (field as any).width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
200
),
})), })),
] ]
return baseColumns return baseColumns
@@ -200,19 +290,57 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
} }
} }
const normalizeValue = useCallback((value: any, field: Field<T>) => {
if (field.fieldType.type === "checkbox") {
if (typeof value === "boolean") return value
if (typeof value === "string") {
const normalizedValue = value.toLowerCase().trim()
if (field.fieldType.booleanMatches) {
return !!field.fieldType.booleanMatches[normalizedValue]
}
return ["yes", "true", "1"].includes(normalizedValue)
}
return false
}
if (field.fieldType.type === "select") {
// Ensure the value matches one of the options
if (field.fieldType.options.some(opt => opt.value === value)) {
return value
}
// Try to match by label
const matchByLabel = field.fieldType.options.find(
opt => opt.label.toLowerCase() === String(value).toLowerCase()
)
return matchByLabel ? matchByLabel.value : value
}
return value
}, [])
const submitData = async () => { const submitData = async () => {
const calculatedData = data.reduce( const calculatedData = data.reduce(
(acc, value) => { (acc, value) => {
const { __index, __errors, ...values } = value const { __index, __errors, ...values } = value
// Normalize values based on field types
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
const field = fields.find((f: Field<T>) => f.key === key)
if (field) {
obj[key as keyof Data<T>] = normalizeValue(val, field)
} else {
obj[key as keyof Data<T>] = val as string | boolean | undefined
}
return obj
}, {} as Data<T>)
if (__errors) { if (__errors) {
for (const key in __errors) { for (const key in __errors) {
if (__errors[key].level === "error") { if (__errors[key].level === "error") {
acc.invalidData.push(values as unknown as Data<T>) acc.invalidData.push(normalizedValues)
return acc return acc
} }
} }
} }
acc.validData.push(values as unknown as Data<T>) acc.validData.push(normalizedValues)
return acc return acc
}, },
{ validData: [] as Data<T>[], invalidData: [] as Data<T>[], all: data }, { validData: [] as Data<T>[], invalidData: [] as Data<T>[], all: data },
@@ -308,49 +436,64 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
</div> </div>
</div> </div>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border overflow-hidden">
<Table> <div className="overflow-x-auto">
<TableHeader> <Table>
{table.getHeaderGroups().map((headerGroup) => ( <TableHeader>
<TableRow key={headerGroup.id}> {table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => ( <TableRow key={headerGroup.id}>
<TableHead key={header.id}> {headerGroup.headers.map((header) => (
{header.isPlaceholder <TableHead
? null key={header.id}
: flexRender( style={{
header.column.columnDef.header, width: header.getSize(),
header.getContext() minWidth: header.getSize(),
)} }}
</TableHead> >
))} {header.isPlaceholder
</TableRow> ? null
))} : flexRender(
</TableHeader> header.column.columnDef.header,
<TableBody> header.getContext()
{table.getRowModel().rows?.length ? ( )}
table.getRowModel().rows.map((row) => ( </TableHead>
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="p-2">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))} ))}
</TableRow> </TableRow>
)) ))}
) : ( </TableHeader>
<TableRow> <TableBody>
<TableCell colSpan={columns.length} className="h-24 text-center"> {table.getRowModel().rows?.length ? (
{filterByErrors table.getRowModel().rows.map((row) => (
? translations.validationStep.noRowsMessageWhenFiltered <TableRow
: translations.validationStep.noRowsMessage} key={row.id}
</TableCell> data-state={row.getIsSelected() && "selected"}
</TableRow> >
)} {row.getVisibleCells().map((cell) => (
</TableBody> <TableCell
</Table> key={cell.id}
className="p-2"
style={{
width: cell.column.getSize(),
minWidth: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{filterByErrors
? translations.validationStep.noRowsMessageWhenFiltered
: translations.validationStep.noRowsMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,11 +10,13 @@ const IMPORT_FIELDS = [
{ {
label: "Name", label: "Name",
key: "name", key: "name",
alternateMatches: ["product", "product name", "item name"], alternateMatches: ["product", "product name", "item name", "title"],
fieldType: { fieldType: {
type: "input", type: "input",
}, },
example: "Widget X", example: "Widget X",
description: "The name or title of the product",
width: 300,
validations: [ validations: [
{ {
rule: "required", rule: "required",
@@ -26,35 +28,154 @@ const IMPORT_FIELDS = [
{ {
label: "SKU", label: "SKU",
key: "sku", key: "sku",
alternateMatches: ["item number", "product code"], alternateMatches: ["item number", "product code", "product id", "item id"],
fieldType: { fieldType: {
type: "input", type: "input",
}, },
example: "WX-123", example: "WX-123",
description: "Unique product identifier",
width: 120,
validations: [ validations: [
{ {
rule: "required", rule: "required",
errorMessage: "SKU is required", errorMessage: "SKU is required",
level: "error", level: "error",
}, },
{
rule: "unique",
errorMessage: "SKU must be unique",
level: "error",
},
], ],
}, },
{
label: "Category",
key: "category",
alternateMatches: ["product category", "type", "product type"],
fieldType: {
type: "select",
options: [
{ label: "Electronics", value: "electronics" },
{ label: "Clothing", value: "clothing" },
{ label: "Food & Beverage", value: "food_beverage" },
{ label: "Office Supplies", value: "office_supplies" },
{ label: "Other", value: "other" },
],
},
width: 150,
validations: [
{
rule: "required",
errorMessage: "Category is required",
level: "error",
},
],
example: "Electronics",
description: "Product category",
},
{ {
label: "Quantity", label: "Quantity",
key: "quantity", key: "quantity",
alternateMatches: ["qty", "stock", "amount"], alternateMatches: ["qty", "stock", "amount", "inventory", "stock level"],
fieldType: { fieldType: {
type: "input", type: "input",
}, },
example: "100", example: "100",
description: "Current stock quantity",
width: 100,
validations: [ validations: [
{ {
rule: "required", rule: "required",
errorMessage: "Quantity is required", errorMessage: "Quantity is required",
level: "error", level: "error",
}, },
{
rule: "regex",
value: "^[0-9]+$",
errorMessage: "Quantity must be a positive number",
level: "error",
},
], ],
}, },
{
label: "Price",
key: "price",
alternateMatches: ["unit price", "cost", "selling price", "retail price"],
fieldType: {
type: "input",
},
example: "29.99",
description: "Selling price per unit",
width: 100,
validations: [
{
rule: "required",
errorMessage: "Price is required",
level: "error",
},
{
rule: "regex",
value: "^\\d*\\.?\\d+$",
errorMessage: "Price must be a valid number",
level: "error",
},
],
},
{
label: "In Stock",
key: "inStock",
alternateMatches: ["available", "active", "status"],
fieldType: {
type: "checkbox",
booleanMatches: {
yes: true,
no: false,
"in stock": true,
"out of stock": false,
available: true,
unavailable: false,
},
},
width: 80,
example: "Yes",
description: "Whether the item is currently in stock",
},
{
label: "Minimum Stock",
key: "minStock",
alternateMatches: ["min qty", "reorder point", "low stock level"],
fieldType: {
type: "input",
},
example: "10",
description: "Minimum stock level before reorder",
width: 100,
validations: [
{
rule: "regex",
value: "^[0-9]+$",
errorMessage: "Minimum stock must be a positive number",
level: "error",
},
],
},
{
label: "Location",
key: "location",
alternateMatches: ["storage location", "warehouse", "shelf", "bin"],
fieldType: {
type: "select",
options: [
{ label: "Warehouse A", value: "warehouse_a" },
{ label: "Warehouse B", value: "warehouse_b" },
{ label: "Store Front", value: "store_front" },
{ label: "External Storage", value: "external" },
],
},
width: 150,
example: "Warehouse A",
description: "Storage location of the product",
},
]; ];
export function Import() { export function Import() {