Fix up validation step to show proper components
This commit is contained in:
@@ -2,8 +2,7 @@ import { useCallback, useMemo, useState } from "react"
|
||||
import { useRsi } from "../../hooks/useRsi"
|
||||
import type { Meta } from "./types"
|
||||
import { addErrorsAndRunHooks } from "./utils/dataMutations"
|
||||
import { generateColumns } from "./components/columns"
|
||||
import type { Data } from "../../types"
|
||||
import type { Data, Field, SelectOption } from "../../types"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -36,6 +35,13 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
type Props<T extends string> = {
|
||||
initialData: (Data<T> & Meta)[]
|
||||
@@ -43,49 +49,125 @@ type Props<T extends string> = {
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
const EditableCell = ({ value, onChange, error }: { value: string, onChange: (value: string) => void, error?: { level: string } }) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value || "")
|
||||
type CellProps = {
|
||||
value: any,
|
||||
onChange: (value: any) => void,
|
||||
error?: { level: string, message: string },
|
||||
field: Field<string>
|
||||
}
|
||||
|
||||
// Always show input for error cells
|
||||
if (error?.level === "error" || isEditing) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
const EditableCell = ({ value, onChange, error, field }: CellProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value ?? "")
|
||||
|
||||
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
|
||||
if (fieldType.type === "select") {
|
||||
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
|
||||
}
|
||||
if (fieldType.type === "checkbox") {
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No"
|
||||
return value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
onClick={() => {
|
||||
setIsEditing(true)
|
||||
setInputValue(value || "")
|
||||
if (field.fieldType.type !== "checkbox") {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -158,12 +240,13 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 50,
|
||||
},
|
||||
...generateColumns(fields).map((col): ColumnDef<Data<T> & Meta> => ({
|
||||
accessorKey: col.key,
|
||||
header: col.name,
|
||||
...fields.map((field: Field<T>): ColumnDef<Data<T> & Meta> => ({
|
||||
accessorKey: field.key,
|
||||
header: field.label,
|
||||
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 rowIndex = row.index
|
||||
|
||||
@@ -172,9 +255,16 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
|
||||
value={value}
|
||||
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
|
||||
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
|
||||
@@ -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 calculatedData = data.reduce(
|
||||
(acc, 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) {
|
||||
for (const key in __errors) {
|
||||
if (__errors[key].level === "error") {
|
||||
acc.invalidData.push(values as unknown as Data<T>)
|
||||
acc.invalidData.push(normalizedValues)
|
||||
return acc
|
||||
}
|
||||
}
|
||||
}
|
||||
acc.validData.push(values as unknown as Data<T>)
|
||||
acc.validData.push(normalizedValues)
|
||||
return acc
|
||||
},
|
||||
{ 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 className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<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>
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
minWidth: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{filterByErrors
|
||||
? translations.validationStep.noRowsMessageWhenFiltered
|
||||
: translations.validationStep.noRowsMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
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>
|
||||
|
||||
@@ -10,11 +10,13 @@ const IMPORT_FIELDS = [
|
||||
{
|
||||
label: "Name",
|
||||
key: "name",
|
||||
alternateMatches: ["product", "product name", "item name"],
|
||||
alternateMatches: ["product", "product name", "item name", "title"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
},
|
||||
example: "Widget X",
|
||||
description: "The name or title of the product",
|
||||
width: 300,
|
||||
validations: [
|
||||
{
|
||||
rule: "required",
|
||||
@@ -26,35 +28,154 @@ const IMPORT_FIELDS = [
|
||||
{
|
||||
label: "SKU",
|
||||
key: "sku",
|
||||
alternateMatches: ["item number", "product code"],
|
||||
alternateMatches: ["item number", "product code", "product id", "item id"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
},
|
||||
example: "WX-123",
|
||||
description: "Unique product identifier",
|
||||
width: 120,
|
||||
validations: [
|
||||
{
|
||||
rule: "required",
|
||||
errorMessage: "SKU is required",
|
||||
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",
|
||||
key: "quantity",
|
||||
alternateMatches: ["qty", "stock", "amount"],
|
||||
alternateMatches: ["qty", "stock", "amount", "inventory", "stock level"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
},
|
||||
example: "100",
|
||||
description: "Current stock quantity",
|
||||
width: 100,
|
||||
validations: [
|
||||
{
|
||||
rule: "required",
|
||||
errorMessage: "Quantity is required",
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user