214 lines
6.5 KiB
TypeScript
214 lines
6.5 KiB
TypeScript
import { Button } from "@workspace/ui/components/button";
|
|
import { Label } from "@workspace/ui/components/label";
|
|
import { Switch } from "@workspace/ui/components/switch";
|
|
import { Textarea } from "@workspace/ui/components/textarea";
|
|
import { Combobox } from "@workspace/ui/composed/combobox";
|
|
import {
|
|
EnhancedInput,
|
|
type EnhancedInputProps,
|
|
} from "@workspace/ui/composed/enhanced-input";
|
|
import { cn } from "@workspace/ui/lib/utils";
|
|
import { CircleMinusIcon, CirclePlusIcon } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
interface FieldConfig extends Omit<EnhancedInputProps, "type"> {
|
|
name: string;
|
|
type: "text" | "number" | "select" | "time" | "boolean" | "textarea";
|
|
options?: { label: string; value: string }[];
|
|
// optional per-item visibility function: returns true to show the field for the given item
|
|
visible?: (item: Record<string, any>) => boolean;
|
|
}
|
|
|
|
interface ObjectInputProps<T> {
|
|
value: T;
|
|
onChange: (value: T) => void;
|
|
fields: FieldConfig[];
|
|
className?: string;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function ObjectInput<T extends Record<string, any>>({
|
|
value,
|
|
onChange,
|
|
fields,
|
|
className,
|
|
}: ObjectInputProps<T>) {
|
|
const [internalState, setInternalState] = useState<T>(value);
|
|
|
|
useEffect(() => {
|
|
setInternalState(value);
|
|
}, [value]);
|
|
|
|
const updateField = (key: keyof T, fieldValue: string | number | boolean) => {
|
|
const updatedInternalState = { ...internalState, [key]: fieldValue };
|
|
setInternalState(updatedInternalState);
|
|
onChange(updatedInternalState);
|
|
};
|
|
const renderField = (field: FieldConfig) => {
|
|
// if visible callback exists and returns false for current item, don't render
|
|
if (field.visible && !field.visible(internalState)) return null;
|
|
switch (field.type) {
|
|
case "select":
|
|
return (
|
|
field.options && (
|
|
<Combobox<string, false>
|
|
onChange={(fieldValue) => updateField(field.name, fieldValue)}
|
|
options={field.options}
|
|
placeholder={field.placeholder}
|
|
value={internalState[field.name]}
|
|
/>
|
|
)
|
|
);
|
|
case "boolean":
|
|
return (
|
|
<div className="flex h-full items-center space-x-2">
|
|
<Switch
|
|
checked={internalState[field.name] as boolean}
|
|
onCheckedChange={(fieldValue) =>
|
|
updateField(field.name, fieldValue)
|
|
}
|
|
/>
|
|
{field.placeholder && <Label>{field.placeholder}</Label>}
|
|
</div>
|
|
);
|
|
case "textarea":
|
|
return (
|
|
<div className="w-full space-y-2">
|
|
{field.prefix && (
|
|
<Label className="font-medium text-sm">{field.prefix}</Label>
|
|
)}
|
|
<Textarea
|
|
className="min-h-32"
|
|
onChange={(e) => updateField(field.name, e.target.value)}
|
|
placeholder={field.placeholder}
|
|
value={internalState[field.name] || ""}
|
|
/>
|
|
</div>
|
|
);
|
|
default:
|
|
return (
|
|
<EnhancedInput
|
|
onValueChange={(fieldValue) => updateField(field.name, fieldValue)}
|
|
value={internalState[field.name]}
|
|
{...field}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
return (
|
|
<div className={cn("flex flex-1 flex-wrap gap-4", className)}>
|
|
{fields.map((field) => {
|
|
const node = renderField(field);
|
|
if (node === null) return null; // don't render wrapper if field hidden
|
|
return (
|
|
<div className={cn("flex-1", field.className)} key={field.name}>
|
|
{node}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
interface ArrayInputProps<T> {
|
|
value?: T[];
|
|
onChange: (value: T[]) => void;
|
|
fields: FieldConfig[];
|
|
isReverse?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function ArrayInput<T extends Record<string, any>>({
|
|
value = [],
|
|
onChange,
|
|
fields,
|
|
isReverse = false,
|
|
className,
|
|
}: ArrayInputProps<T>) {
|
|
const initializeDefaultItem = (): T =>
|
|
fields.reduce((acc, field) => {
|
|
acc[field.name as keyof T] = undefined as T[keyof T];
|
|
return acc;
|
|
}, {} as T);
|
|
|
|
const [displayItems, setDisplayItems] = useState<T[]>(() =>
|
|
value.length > 0 ? value : [initializeDefaultItem()]
|
|
);
|
|
|
|
const isItemModified = (item: T): boolean =>
|
|
fields.some((field) => {
|
|
const val = item[field.name];
|
|
return val !== undefined && val !== null && val !== "";
|
|
});
|
|
|
|
const handleItemChange = (index: number, updatedItem: T) => {
|
|
const newDisplayItems = [...displayItems];
|
|
newDisplayItems[index] = updatedItem;
|
|
setDisplayItems(newDisplayItems);
|
|
|
|
const modifiedItems = newDisplayItems.filter(isItemModified);
|
|
onChange(modifiedItems);
|
|
};
|
|
|
|
const createField = () => {
|
|
if (isReverse) {
|
|
setDisplayItems([initializeDefaultItem(), ...displayItems]);
|
|
} else {
|
|
setDisplayItems([...displayItems, initializeDefaultItem()]);
|
|
}
|
|
};
|
|
|
|
const deleteField = (index: number) => {
|
|
const newDisplayItems = displayItems.filter((_, i) => i !== index);
|
|
setDisplayItems(newDisplayItems);
|
|
|
|
const modifiedItems = newDisplayItems.filter(isItemModified);
|
|
onChange(modifiedItems);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (value.length > 0) {
|
|
setDisplayItems(value);
|
|
}
|
|
}, [value]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{displayItems.map((item, index) => (
|
|
<div className="flex items-center gap-4" key={index}>
|
|
<ObjectInput
|
|
className={className}
|
|
fields={fields}
|
|
onChange={(updatedItem) => handleItemChange(index, updatedItem)}
|
|
value={item}
|
|
/>
|
|
<div className="flex min-w-20 items-center">
|
|
{displayItems.length > 1 && (
|
|
<Button
|
|
className="p-0 text-destructive text-lg"
|
|
onClick={() => deleteField(index)}
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<CircleMinusIcon />
|
|
</Button>
|
|
)}
|
|
{(isReverse ? index === 0 : index === displayItems.length - 1) && (
|
|
<Button
|
|
className="p-0 text-lg text-primary"
|
|
onClick={createField}
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<CirclePlusIcon />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|