Clean up linter errors

This commit is contained in:
2025-02-19 22:05:21 -05:00
parent 24e2d01ccc
commit 468f85c45d
5 changed files with 223 additions and 168 deletions

View File

@@ -7,7 +7,7 @@ import { setColumn } from "./utils/setColumn"
import { setIgnoreColumn } from "./utils/setIgnoreColumn"
import { setSubColumn } from "./utils/setSubColumn"
import { normalizeTableData } from "./utils/normalizeTableData"
import type { Field, RawData } from "../../types"
import type { Field, RawData, Fields } from "../../types"
import { getMatchedColumns } from "./utils/getMatchedColumns"
import { findUnmatchedRequiredFields } from "./utils/findUnmatchedRequiredFields"
import { toast } from "sonner"
@@ -23,6 +23,7 @@ import {
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
import { DeepReadonly as TsDeepReadonly } from "ts-essentials"
export type MatchColumnsProps<T extends string> = {
data: RawData[]
@@ -91,6 +92,8 @@ export type Column<T extends string> =
export type Columns<T extends string> = Column<T>[]
type ReadonlyField<T extends string> = TsDeepReadonly<Field<T>>;
export const MatchColumnsStep = <T extends string>({
data,
headerValues,
@@ -108,7 +111,7 @@ export const MatchColumnsStep = <T extends string>({
const onChange = useCallback(
(value: T, columnIndex: number) => {
const field = fields.find((field: Field<T>) => field.key === value)
const field = (fields as Fields<T>).find((f) => f.key === value)
if (!field) return
const existingFieldIndex = columns.findIndex((column) => "value" in column && column.value === field.key)
@@ -117,7 +120,7 @@ export const MatchColumnsStep = <T extends string>({
columns.map<Column<T>>((column, index) => {
if (columnIndex === index) {
// Set the new column value
return setColumn(column, field, data, autoMapSelectValues)
return setColumn(column, field as Field<T>, data, autoMapSelectValues)
} else if (index === existingFieldIndex) {
// Clear the old column that had this field
toast.warning(translations.matchColumnsStep.duplicateColumnWarningTitle, {
@@ -159,13 +162,24 @@ export const MatchColumnsStep = <T extends string>({
(value: string, columnIndex: number, entry: string) => {
setColumns(
columns.map((column, index) =>
columnIndex === index && "matchedOptions" in column ? setSubColumn(column, entry, value) : column,
columnIndex === index && "matchedOptions" in column
? setSubColumn(column as MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T>, entry, value)
: column,
),
)
},
[columns, setColumns],
)
const unmatchedRequiredFields = useMemo(() => findUnmatchedRequiredFields(fields, columns), [fields, columns])
const unmatchedRequiredFields = useMemo(() => {
// Convert the fields to the expected type
const fieldsArray = Array.isArray(fields) ? fields : [fields]
const typedFields = fieldsArray.map(field => ({
...field,
key: field.key as TsDeepReadonly<T>
})) as unknown as Fields<T>
return findUnmatchedRequiredFields(typedFields, columns)
}, [fields, columns])
const handleOnContinue = useCallback(async () => {
if (unmatchedRequiredFields.length > 0) {

View File

@@ -1,7 +1,7 @@
import { useRsi } from "../../../hooks/useRsi"
import type { Column } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import type { Fields, Field } from "../../../types"
import type { Fields } from "../../../types"
import {
Card,
CardContent,
@@ -27,10 +27,12 @@ type TemplateColumnProps<T extends string> = {
onChange: (value: T, columnIndex: number) => void
onSubChange: (value: string, columnIndex: number, entry: string) => void
}
const getAccordionTitle = <T extends string>(fields: Fields<T>, column: Column<T>, translations: any) => {
const fieldLabel = fields.find((field: Field<T>) => "value" in column && field.key === column.value)!.label
return `${translations.matchColumnsStep.matchDropdownTitle} ${fieldLabel} (${
"matchedOptions" in column && column.matchedOptions.filter((option) => !option.value).length
const field = fields.find((f) => "value" in column && f.key === column.value)
if (!field) return ""
return `${translations.matchColumnsStep.matchDropdownTitle} ${field.label} (${
"matchedOptions" in column ? column.matchedOptions.filter((option) => !option.value).length : 0
} ${translations.matchColumnsStep.unmatched})`
}
@@ -42,9 +44,9 @@ export const TemplateColumn = <T extends string>({ column, onChange, onSubChange
column.type === ColumnType.matchedCheckbox ||
column.type === ColumnType.matchedSelectOptions
const isSelect = "matchedOptions" in column
const selectOptions = fields.map(({ label, key }: { label: string; key: string }) => ({ value: key, label }))
const selectOptions = fields.map(({ label, key }) => ({ value: key, label }))
const selectValue = column.type === ColumnType.empty ? undefined :
selectOptions.find(({ value }: { value: string }) => "value" in column && column.value === value)?.value
selectOptions.find(({ value }) => "value" in column && column.value === value)?.value
if (isIgnored) {
return null
@@ -67,7 +69,7 @@ export const TemplateColumn = <T extends string>({ column, onChange, onSubChange
align="start"
className="z-[1500]"
>
{selectOptions.map((option: { value: string; label: string }) => (
{selectOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@@ -86,7 +88,7 @@ export const TemplateColumn = <T extends string>({ column, onChange, onSubChange
<Accordion type="multiple" className="w-full">
<AccordionItem value="options" className="border-none">
<AccordionTrigger className="py-2 text-sm hover:no-underline">
{getAccordionTitle<T>(fields, column, translations)}
{getAccordionTitle(fields, column, translations)}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
@@ -107,13 +109,15 @@ export const TemplateColumn = <T extends string>({ column, onChange, onSubChange
align="start"
className="z-[1000]"
>
{fields
.find((field: Field<T>) => "value" in column && field.key === column.value)
?.fieldType.options.map((option: { value: string; label: string }) => (
{(() => {
const field = fields.find((f) => "value" in column && f.key === column.value)
if (!field || !("fieldType" in field) || !("options" in field.fieldType)) return null
return field.fieldType.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
))
})()}
</SelectContent>
</Select>
</div>

View File

@@ -1,12 +1,18 @@
import { ColumnType, MatchedOptions, MatchedSelectColumn, MatchedSelectOptionsColumn } from "../MatchColumnsStep"
import { ColumnType, MatchedOptions, MatchedSelectColumn, MatchedSelectOptionsColumn, MatchedMultiSelectColumn } from "../MatchColumnsStep"
export const setSubColumn = <T>(
oldColumn: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>,
oldColumn: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T>,
entry: string,
value: string,
): MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> => {
): MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> | MatchedMultiSelectColumn<T> => {
const options = oldColumn.matchedOptions.map((option) => (option.entry === entry ? { ...option, value } : option))
const allMathced = options.every(({ value }) => !!value)
if (allMathced) {
const allMatched = options.every(({ value }) => !!value)
if (oldColumn.type === ColumnType.matchedMultiSelect) {
return { ...oldColumn, matchedOptions: options as MatchedOptions<T>[] }
}
if (allMatched) {
return { ...oldColumn, matchedOptions: options as MatchedOptions<T>[], type: ColumnType.matchedSelectOptions }
} else {
return { ...oldColumn, matchedOptions: options as MatchedOptions<T>[], type: ColumnType.matchedSelect }

View File

@@ -3,7 +3,7 @@ import { useRsi } from "../../hooks/useRsi"
import type { Meta } from "./types"
import { addErrorsAndRunHooks } from "./utils/dataMutations"
import type { Data, Field, SelectOption, MultiInput } from "../../types"
import { Check, ChevronsUpDown, ArrowDown } from "lucide-react"
import { Check, ChevronsUpDown, ArrowDown, AlertCircle } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Command,
@@ -50,6 +50,12 @@ import {
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
type Props<T extends string> = {
initialData: (Data<T> & Meta)[]
@@ -68,6 +74,7 @@ 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 regexValidation = field.validations?.find(v => v.rule === "regex")
@@ -100,22 +107,19 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
return value
}
const isRequired = field.validations?.some(v => v.rule === "required")
// 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
if (value) {
// 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
} else if (isRequired && !isEditing) {
// Only show required validation when not editing and empty
return { level: "error", message: "Required" }
}
// Never show required validation for empty cells
return undefined
}
@@ -145,21 +149,32 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
// 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 } }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute right-2 top-2 text-destructive">
<AlertCircle className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{error.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
if (shouldShowEditUI) {
switch (field.fieldType.type) {
case "select":
return (
<div className="space-y-1">
<Popover open={isEditing} onOpenChange={(open) => {
if (!open) handleBlur()
setIsEditing(open)
}}>
<div className="relative">
<Popover open={isEditing} onOpenChange={setIsEditing}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -167,7 +182,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
aria-expanded={isEditing}
className={cn(
"w-full justify-between",
currentError ? "border-destructive text-destructive" : "border-input"
currentError ? "border-destructive" : "border-input"
)}
disabled={field.disabled}
>
@@ -177,13 +192,22 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Search options..." className="h-9" />
<CommandList>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" side="bottom">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search options..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options.map((option) => (
{field.fieldType.options
.filter(option =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
.map((option) => (
<CommandItem
key={option.value}
value={option.value}
@@ -192,6 +216,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
if (field.onChange) {
field.onChange(currentValue)
}
setSearchQuery("")
setIsEditing(false)
}}
>
@@ -209,18 +234,17 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
</Command>
</PopoverContent>
</Popover>
{currentError && (
<p className="text-xs text-destructive">{currentError.message}</p>
)}
{currentError && <ValidationIcon error={currentError} />}
</div>
)
case "multi-select":
const selectedValues = Array.isArray(value) ? value : value ? [value] : []
return (
<Popover open={isEditing} onOpenChange={(open) => {
if (!open) handleBlur()
setIsEditing(open)
}}>
<div className="relative">
<Popover
open={isEditing}
onOpenChange={setIsEditing}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -228,7 +252,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
aria-expanded={isEditing}
className={cn(
"w-full justify-between",
currentError ? "border-destructive text-destructive" : "border-input"
currentError ? "border-destructive" : "border-input"
)}
>
{selectedValues.length > 0
@@ -237,13 +261,22 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Search options..." className="h-9" />
<CommandList>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" side="bottom">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search options..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options.map((option) => (
{field.fieldType.options
.filter(option =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
.map((option) => (
<CommandItem
key={option.value}
value={option.value}
@@ -256,7 +289,6 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
newValues = selectedValues.filter((_, i) => i !== valueIndex)
}
onChange(newValues)
// Don't close on selection for multi-select
}}
>
<div className="flex items-center gap-2">
@@ -273,42 +305,25 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
</Command>
</PopoverContent>
</Popover>
{currentError && <ValidationIcon error={currentError} />}
</div>
)
case "checkbox":
return (
<div className="flex items-center gap-2">
<div className="relative flex items-center gap-2">
<Checkbox
checked={Boolean(value)}
onCheckedChange={(checked) => {
onChange(checked)
}}
/>
{currentError && <ValidationIcon error={currentError} />}
</div>
)
case "multi-input":
return (
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBlur()
}
}}
onBlur={handleBlur}
className={cn(
"w-full bg-transparent",
currentError ? "border-destructive text-destructive" : ""
)}
autoFocus={!error}
placeholder={`Enter values separated by ${(field.fieldType as MultiInput).separator || ","}`}
/>
)
default:
return (
<div className="space-y-1">
<div className="relative">
<Input
value={inputValue}
onChange={(e) => {
@@ -317,18 +332,23 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBlur()
setIsEditing(false)
}
}}
onBlur={handleBlur}
onBlur={() => {
handleBlur()
setIsEditing(false)
}}
className={cn(
"w-full bg-transparent",
currentError ? "border-destructive text-destructive" : ""
"w-full",
currentError ? "border-destructive" : ""
)}
autoFocus={!error}
autoFocus
placeholder={field.fieldType.type === "multi-input"
? `Enter values separated by ${(field.fieldType as MultiInput).separator || ","}`
: undefined}
/>
{currentError && (
<p className="text-xs text-destructive">{currentError.message}</p>
)}
{currentError && <ValidationIcon error={currentError} />}
</div>
)
}
@@ -344,7 +364,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
}
}}
className={cn(
"min-h-[36px] cursor-text p-2 rounded-md border bg-background",
"relative min-h-[36px] cursor-text p-2 rounded-md border bg-background",
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"
@@ -356,11 +376,7 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} />
)}
{currentError && (
<div className="absolute left-0 -bottom-5 text-xs text-destructive">
{currentError.message}
</div>
)}
{currentError && <ValidationIcon error={currentError} />}
</div>
)
}
@@ -440,6 +456,24 @@ const CopyDownDialog = ({
)
}
// Add type utilities at the top level
type DeepReadonlyField<T extends string> = {
readonly label: string
readonly key: T
readonly description?: string
readonly alternateMatches?: readonly string[]
readonly validations?: readonly ({ rule: string } & Record<string, any>)[]
readonly fieldType: {
readonly type: string
readonly options?: readonly SelectOption[]
readonly booleanMatches?: { readonly [key: string]: boolean }
}
readonly onChange?: (value: string) => void
}
type ReadonlyField<T extends string> = Readonly<Field<T>>;
type ReadonlyFields<T extends string> = readonly ReadonlyField<T>[];
export const ValidationStep = <T extends string>({ initialData, file, onBack }: Props<T>) => {
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>()
const { toast } = useToast()
@@ -508,7 +542,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
setCopyDownField(null)
}, [data, updateData, copyDownField])
const columns = useMemo<ColumnDef<Data<T> & Meta>[]>(() => {
const columns = useMemo(() => {
const baseColumns: ColumnDef<Data<T> & Meta>[] = [
{
id: "select",
@@ -530,12 +564,12 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
enableHiding: false,
size: 50,
},
...fields.map((field: Field<T>): ColumnDef<Data<T> & Meta> => ({
...(Array.from(fields as ReadonlyFields<T>).map((field): ColumnDef<Data<T> & Meta> => ({
accessorKey: field.key,
header: () => (
<div className="group">
<ColumnHeader
field={field}
field={field as Field<T>}
data={data}
onCopyDown={(key) => copyValueDown(key, field.label)}
/>
@@ -551,7 +585,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
value={value}
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
error={error}
field={field}
field={field as Field<string>}
/>
)
},
@@ -560,7 +594,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
field.fieldType.type === "select" ? 150 :
200
),
})),
})))
]
return baseColumns
}, [fields, updateRows, data, copyValueDown])
@@ -585,7 +619,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
}
}
const normalizeValue = useCallback((value: any, field: Field<T>) => {
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>) => {
if (field.fieldType.type === "checkbox") {
if (typeof value === "boolean") return value
if (typeof value === "string") {
@@ -597,7 +631,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
}
return false
}
if (field.fieldType.type === "select") {
if (field.fieldType.type === "select" && field.fieldType.options) {
// Ensure the value matches one of the options
if (field.fieldType.options.some(opt => opt.value === value)) {
return value
@@ -616,11 +650,10 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
(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)
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key)
if (field) {
obj[key as keyof Data<T>] = normalizeValue(val, 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
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { ReactSpreadsheetImport } from "@/lib/react-spreadsheet-import/src";
import type { Field, Fields, Validation, ErrorLevel } from "@/lib/react-spreadsheet-import/src/types";
import type { ErrorLevel } from "@/lib/react-spreadsheet-import/src/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Code } from "@/components/ui/code";
@@ -333,8 +333,6 @@ const BASE_IMPORT_FIELDS = [
},
] as const;
type ImportField = typeof BASE_IMPORT_FIELDS[number];
type ImportFieldKey = ImportField["key"];
export function Import() {
const [isOpen, setIsOpen] = useState(false);