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 { 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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user