diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx index f1b938e..3837529 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -92,7 +92,6 @@ export type Column = export type Columns = Column[] -type ReadonlyField = TsDeepReadonly>; export const MatchColumnsStep = ({ data, diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx index ca4a6ea..d8cc2db 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx @@ -56,6 +56,12 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" +import * as Select from "@radix-ui/react-select" +import { + Dialog, + DialogContent, + DialogTrigger, +} from "@/components/ui/dialog" type Props = { initialData: (Data & Meta)[] @@ -73,20 +79,60 @@ type CellProps = { const EditableCell = ({ value, onChange, error, field }: CellProps) => { const [isEditing, setIsEditing] = useState(false) const [inputValue, setInputValue] = useState(value ?? "") - const [validationError, setValidationError] = useState<{level: string, message: string} | undefined>(error) const [searchQuery, setSearchQuery] = useState("") - - const validateRegex = (val: string) => { + const [localValues, setLocalValues] = useState([]) + + // Update input value when external value changes and we're not editing + useEffect(() => { + if (!isEditing) { + setInputValue(value ?? "") + } + }, [value, isEditing]) + + // Keep localValues in sync with value for multi-select + useEffect(() => { + if (field.fieldType.type === "multi-select") { + const selectedValues = Array.isArray(value) ? value : value ? [value] : [] + setLocalValues(selectedValues) + } + }, [value, field.fieldType.type]) + + const validateRegex = (val: any) => { + // Handle non-string values + if (val === undefined || val === null) return undefined + if (Array.isArray(val)) { + // For arrays (multi-select), join values with comma + val = val.join(", ") + } + if (typeof val === "boolean") { + // For booleans, convert to "Yes"/"No" + val = val ? "Yes" : "No" + } + + // Convert to string and check if empty/whitespace + const strVal = String(val) + if (!strVal || !strVal.trim()) return undefined + const regexValidation = field.validations?.find(v => v.rule === "regex") - if (regexValidation && val) { + if (regexValidation) { const regex = new RegExp(regexValidation.value, regexValidation.flags) - if (!regex.test(val)) { + if (!regex.test(strVal)) { return { level: regexValidation.level || "error", message: regexValidation.errorMessage } } } return undefined } + // Only show validation errors when not editing and value is invalid + const getValidationError = () => { + if (isEditing) return undefined + // Only validate if we have a non-empty value + if (!value || !value.toString().trim()) return undefined + return error || validateRegex(value) + } + + const currentError = getValidationError() + const getDisplayValue = (value: any, fieldType: Field["fieldType"]) => { if (fieldType.type === "select") { return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value @@ -107,53 +153,25 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => { return value } - - // Determine the current validation state - const getValidationState = () => { - // Never show validation during editing - if (isEditing) return undefined - - // Only show validation errors if there's a value and we're not editing - if (value && !isEditing) { - if (error) return error - if (validationError) return validationError + const validateAndCommit = (newValue: string | boolean) => { + // For non-string values (like checkboxes), just commit + if (typeof newValue !== 'string') { + onChange(newValue) + return true } - - // Never show required validation for empty cells - return undefined - } - const currentError = getValidationState() - - useEffect(() => { - // Update validation state when value changes externally (e.g. from copy down) - if (!isEditing) { - const newValidationError = value ? validateRegex(value) : undefined - setValidationError(newValidationError) - } - }, [value]) - - const validateAndCommit = (newValue: string) => { - const regexError = newValue ? validateRegex(newValue) : undefined - setValidationError(regexError) - // Always commit the value onChange(newValue) - // Only exit edit mode if there are no errors (except required field errors) - if (!error && !regexError) { - setIsEditing(false) - } + // Return whether validation passed (only validate non-empty values) + return !validateRegex(newValue) } - // Handle blur for all input types const handleBlur = () => { validateAndCommit(inputValue) + setIsEditing(false) } - // Show editing UI only when actually editing - const shouldShowEditUI = isEditing - const ValidationIcon = ({ error }: { error: { level: string, message: string } }) => ( @@ -169,12 +187,28 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => { ) - if (shouldShowEditUI) { + const handleWheel = (e: React.WheelEvent) => { + const commandList = e.currentTarget; + commandList.scrollTop += e.deltaY; + e.stopPropagation(); + }; + + if (isEditing) { switch (field.fieldType.type) { case "select": return ( -
- +
+ { + if (!open) { + validateAndCommit(value) + setIsEditing(false) + } else { + setIsEditing(true) + } + }} + >
) case "multi-select": - const selectedValues = Array.isArray(value) ? value : value ? [value] : [] return ( -
- + - - - { + setIsEditing(false) + onChange(localValues) + }} + onInteractOutside={(e) => { + const target = e.target as HTMLElement + if (!target.closest('[role="listbox"]')) { + setIsEditing(false) + onChange(localValues) + } + }} + > + + - + No options found. {field.fieldType.options - .filter(option => + .filter(option => option.label.toLowerCase().includes(searchQuery.toLowerCase()) ) .map((option) => ( { - const valueIndex = selectedValues.indexOf(currentValue) - let newValues - if (valueIndex === -1) { - newValues = [...selectedValues, currentValue] - } else { - newValues = selectedValues.filter((_, i) => i !== valueIndex) - } - onChange(newValues) + onSelect={() => { + const valueIndex = localValues.indexOf(option.value) + const newValues = valueIndex === -1 + ? [...localValues, option.value] + : localValues.filter((_, i) => i !== valueIndex) + setLocalValues(newValues) }} + onMouseDown={(e) => e.preventDefault()} > -
- - {option.label} -
+ {option.label} +
))}
@@ -308,37 +353,29 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => { {currentError && }
) - case "checkbox": - return ( -
- { - onChange(checked) - }} - /> - {currentError && } -
- ) case "multi-input": default: return ( -
+
{ setInputValue(e.target.value) }} + onBlur={handleBlur} onKeyDown={(e) => { if (e.key === "Enter") { - handleBlur() - setIsEditing(false) + if (field.fieldType.type === "multi-input") { + const separator = (field.fieldType as MultiInput).separator || "," + const values = inputValue.split(separator).map((v: string) => v.trim()).filter(Boolean) + if (validateAndCommit(values.join(separator))) { + setIsEditing(false) + } + } else if (validateAndCommit(inputValue)) { + setIsEditing(false) + } } }} - onBlur={() => { - handleBlur() - setIsEditing(false) - }} className={cn( "w-full", currentError ? "border-destructive" : "" @@ -357,8 +394,10 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => { // Display mode return (
{ + id={`cell-${field.key}`} + onClick={(e) => { if (field.fieldType.type !== "checkbox" && !field.disabled) { + e.stopPropagation() // Prevent event bubbling setIsEditing(true) setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "") } @@ -370,7 +409,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => { field.disabled && "opacity-50 cursor-not-allowed bg-muted" )} > -
+
{value ? getDisplayValue(value, field.fieldType) : ""}
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && ( @@ -495,33 +534,43 @@ export const ValidationStep = ({ initialData, file, onBack }: const updateData = useCallback( async (rows: typeof data, indexes?: number[]) => { - // Check if hooks are async - if they are we want to apply changes optimistically for better UX + // Set the data immediately first + setData(rows); + + // Then run the hooks if they exist if (rowHook?.constructor.name === "AsyncFunction" || tableHook?.constructor.name === "AsyncFunction") { - setData(rows) + const updatedData = await addErrorsAndRunHooks(rows, fields, rowHook, tableHook, indexes); + setData(updatedData); + } else { + addErrorsAndRunHooks(rows, fields, rowHook, tableHook, indexes).then(setData); } - addErrorsAndRunHooks(rows, fields, rowHook, tableHook, indexes).then((data) => setData(data)) }, [rowHook, tableHook, fields], - ) + ); const updateRows = useCallback( - (rowIndex: number, columnId: string, value: string) => { - const newData = [...data] - // Get the actual row from the filtered or unfiltered data - const row = filteredData[rowIndex] - if (row) { - // Find the original index in the full dataset - const originalIndex = data.findIndex(r => r.__index === row.__index) - const updatedRow = { - ...row, - [columnId]: value, - } - newData[originalIndex] = updatedRow - updateData(newData, [originalIndex]) - } + (rowIndex: number, columnId: string, value: any) => { + const row = filteredData[rowIndex]; + if (!row) return; + + const originalIndex = data.findIndex(r => r.__index === row.__index); + if (originalIndex === -1) return; + + const newData = [...data]; + const updatedRow = { + ...row, + [columnId]: value, + }; + newData[originalIndex] = updatedRow; + + // Update immediately first + setData(newData); + + // Then run the async validation + updateData(newData, [originalIndex]); }, [data, filteredData, updateData], - ) + ); const copyValueDown = useCallback((key: T, label: string) => { setCopyDownField({ key, label })