"use client"; import { useState, useEffect } from 'react'; import { Button, Input, Label } from '../ui'; import { Trash2, Plus, Save, Loader2 } from 'lucide-react'; import { ReactNode, ClipboardEvent } from 'react'; interface ArrayEntryManagerProps { title: ReactNode; // Support string or ReactNode for badge integration items: T[]; onChange: (items: T[]) => void; fieldKey: keyof T; // The key to edit (e.g., 'github_release_url', 'kb_article_path') placeholder: string; emptyMessage: string; addButtonText: string; saveButtonText?: string; // Text for save button when requireSave=true icon?: ReactNode; className?: string; requireSave?: boolean; // If true, show "Save" button and only call onChange when clicked onDirtyChange?: (isDirty: boolean) => void; // Callback when dirty state changes renderLabel?: (item: T, index: number) => ReactNode; // Custom label/badge renderer for each entry /** * An optional SECOND field on each entry, edited above the main one — a * human name for the thing the main field addresses. * * Design docs need it because a link's own title is the only name available: * claude.ai serves the same `og:title` ("Claude Artifact") for every artifact * and its per-artifact metadata endpoint is unreachable server-side, so an * unnamed link can only ever render as its type. Omitted by every other * caller, which keeps their single-field row exactly as it was. */ titleFieldKey?: keyof T; titlePlaceholder?: string; isSaving?: boolean; // Loading state for save button } export function ArrayEntryManager({ title, items, onChange, fieldKey, placeholder, emptyMessage, addButtonText, saveButtonText = 'Save Changes', icon, className = '', requireSave = false, onDirtyChange, renderLabel, titleFieldKey, titlePlaceholder, isSaving = false }: ArrayEntryManagerProps) { // Local state for draft changes (when requireSave=true) const [draftItems, setDraftItems] = useState(items); const [isDirty, setIsDirty] = useState(false); // Sync draft with props when items change from parent (but NOT when editing or saving) useEffect(() => { if (!isDirty && !isSaving) { setDraftItems(items); } }, [items, isDirty, isSaving]); // Notify parent when dirty state changes useEffect(() => { if (onDirtyChange) { onDirtyChange(isDirty); } }, [isDirty, onDirtyChange]); const workingItems = requireSave ? draftItems : items; const setWorkingItems = requireSave ? (newItems: T[]) => { setDraftItems(newItems); setIsDirty(true); } : onChange; const addItem = () => { const newItem = { [fieldKey]: '' } as T; setWorkingItems([newItem, ...workingItems]); // Add at top for better UX }; const removeItem = (index: number) => { setWorkingItems(workingItems.filter((_, i) => i !== index)); }; const updateItem = (index: number, value: string, key: keyof T = fieldKey) => { const updated = [...workingItems]; updated[index] = { ...updated[index], [key]: value }; setWorkingItems(updated); }; const handleSave = async () => { await onChange(draftItems); setIsDirty(false); // Reset after save completes }; // Handle paste of multiple IDs separated by newlines const handlePaste = (index: number, e: ClipboardEvent) => { const pastedText = e.clipboardData.getData('text'); // Split by newlines (handles \n, \r\n, \r) const lines = pastedText.split(/[\r\n]+/).map(line => line.trim()).filter(line => line.length > 0); // If only one line, let default paste behavior handle it if (lines.length <= 1) { return; } // Prevent default paste for multi-line e.preventDefault(); const currentItem = items[index]; const currentValue = (currentItem[fieldKey] as string) || ''; // Build new items array const newItems = [...items]; if (currentValue.trim() === '') { // If current field is empty, use first pasted value for it newItems[index] = { ...newItems[index], [fieldKey]: lines[0] }; // Add remaining lines as new items after current index const additionalItems = lines.slice(1).map(line => ({ [fieldKey]: line } as T)); newItems.splice(index + 1, 0, ...additionalItems); } else { // If current field has value, add all pasted lines as new items after current const additionalItems = lines.map(line => ({ [fieldKey]: line } as T)); newItems.splice(index + 1, 0, ...additionalItems); } onChange(newItems); }; return (
{requireSave && isDirty && ( )}
{workingItems.map((item, index) => (
{icon && (
{icon}
)} {/* min-w-0: a wide renderLabel (e.g. a DeliveryRow) must truncate, not push the row past its host. */}
{renderLabel && renderLabel(item, index)} {titleFieldKey ? ( updateItem(index, e.target.value, titleFieldKey)} onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()} className="bg-ods-bg border-ods-border text-ods-text-primary" /> ) : null} updateItem(index, e.target.value)} onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()} onPaste={(e) => handlePaste(index, e)} className="bg-ods-bg border-ods-border text-ods-text-primary" />
))} {workingItems.length === 0 && (

{emptyMessage}

)}
); }