{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "relation-section", "title": "Relation Section", "description": "Collapsible section for displaying and managing ContentGrid entity relations.", "dependencies": ["lucide-react"], "registryDependencies": [ "alert-dialog", "badge", "button", "card", "collapsible", "skeleton", "table", "tooltip", "utils" ], "files": [ { "path": "src/patterns/relation-section/relation-section.tsx", "content": "import { useState } from \"react\";\nimport { ChevronDown, ExternalLink, LinkIcon, Plus, Unlink } from \"lucide-react\";\nimport { cn, formatCellValue } from \"../../lib/utils\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"../../primitives/alert-dialog\";\nimport { Badge } from \"../../primitives/badge\";\nimport { Button } from \"../../primitives/button\";\nimport { Card, CardContent, CardHeader } from \"../../primitives/card\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"../../primitives/collapsible\";\nimport { Skeleton } from \"../../primitives/skeleton\";\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../../primitives/table\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"../../primitives/tooltip\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/** A single related item row */\nexport interface RelationItem {\n /** Unique identifier for this related item */\n id: string;\n /** Attribute data keyed by attribute name */\n data: Record;\n}\n\n/** Column descriptor for the relation table */\nexport interface RelationColumn {\n /** Attribute name used to look up values in RelationItem.data */\n key: string;\n /** Human-readable column header */\n title: string;\n}\n\nexport interface RelationSectionProps {\n /** Human-readable relation title, e.g. \"Invoices\" */\n title: string;\n /** When true the section renders the to-one (many-to-one) compact card layout */\n isManyToOne?: boolean;\n /** Loaded relation items; undefined while loading */\n items?: RelationItem[];\n /** Column definitions; when empty the section falls back to rendering data keys */\n columns?: RelationColumn[];\n /** True while the relation data is being fetched */\n isLoading?: boolean;\n /** Non-null when the relation data failed to load */\n error?: unknown;\n /** True while an unlink mutation is in progress */\n isUnlinking?: boolean;\n /** Called when the user confirms unlinking an item */\n onUnlink?: (id: string) => void;\n /** Called when the user clicks the \"Link\" / \"Change\" button */\n onLink?: () => void;\n /** Called when the user clicks the detail / external-link icon for an item */\n onViewItem?: (id: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction getItemLabel(item: RelationItem): string {\n const firstVal = Object.entries(item.data).find(\n ([k, v]) => !k.startsWith(\"_\") && k !== \"id\" && v != null,\n );\n return firstVal ? String(firstVal[1]) : item.id;\n}\n\nfunction resolveColumnKeys(items: RelationItem[], columns?: RelationColumn[]): string[] {\n if (columns && columns.length > 0) {\n return columns.map((c) => c.key).filter((k) => items[0] && k in items[0].data);\n }\n if (!items[0]) return [];\n return Object.keys(items[0].data).filter((k) => !k.startsWith(\"_\") && k !== \"id\");\n}\n\nfunction getColumnTitle(key: string, columns?: RelationColumn[]): string {\n const col = columns?.find((c) => c.key === key);\n if (col) return col.title;\n return key.replaceAll(\"_\", \" \").replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\n// ---------------------------------------------------------------------------\n// Main export\n// ---------------------------------------------------------------------------\n\nexport function RelationSection({\n title,\n isManyToOne,\n items,\n columns,\n isLoading,\n error,\n isUnlinking,\n onUnlink,\n onLink,\n onViewItem,\n}: Readonly) {\n const [unlinkTarget, setUnlinkTarget] = useState<{ id: string; label: string } | null>(null);\n\n const hasItems = !isLoading && !error && items && items.length > 0;\n const columnKeys = hasItems ? resolveColumnKeys(items, columns) : [];\n\n function handleUnlink() {\n if (!unlinkTarget) return;\n onUnlink?.(unlinkTarget.id);\n setUnlinkTarget(null);\n }\n\n const unlinkDialog = onUnlink ? (\n !open && setUnlinkTarget(null)}\n >\n \n \n Unlink {title.toLowerCase()}\n \n Remove the link to “{unlinkTarget?.label}”? This will not delete the{\" \"}\n {title.toLowerCase()} itself.\n \n \n \n Cancel\n \n {isUnlinking ? \"Unlinking...\" : \"Unlink\"}\n \n \n \n \n ) : null;\n\n // -------------------------------------------------------------------------\n // Many-to-one layout: compact card\n // -------------------------------------------------------------------------\n if (isManyToOne) {\n return (\n \n \n \n
\n

{title}

\n {hasItems && onLink && (\n \n \n Change\n \n )}\n
\n
\n \n {isLoading && (\n
\n \n
\n )}\n {!!error &&

Failed to load relation data.

}\n {!isLoading && !error && !hasItems && (\n
\n
\n \n
\n
\n

No {title.toLowerCase()} linked

\n

\n Link a {title.toLowerCase()} to this item\n

\n
\n {onLink && (\n \n )}\n
\n )}\n {hasItems &&\n items.map((item) => {\n const label = getItemLabel(item);\n return (\n
\n
\n

{label}

\n
\n {columnKeys\n .filter((k) => item.data[k] != null)\n .slice(0, 3)\n .map((key) => (\n \n {getColumnTitle(key, columns)}: {String(item.data[key])}\n \n ))}\n
\n
\n
\n {onViewItem && (\n \n \n onViewItem(item.id)}\n >\n \n View details\n \n \n View details\n \n )}\n {onUnlink && (\n \n \n setUnlinkTarget({ id: item.id, label })}\n >\n \n Unlink\n \n \n Unlink\n \n )}\n
\n
\n );\n })}\n
\n
\n {unlinkDialog}\n
\n );\n }\n\n // -------------------------------------------------------------------------\n // Many-to-many layout: collapsible table\n // -------------------------------------------------------------------------\n const itemCount = items?.length ?? 0;\n\n return (\n \n \n {!isLoading && !error && !hasItems ? (\n <>\n \n
\n

{title}

\n
\n
\n \n
\n
\n \n
\n
\n

No {title.toLowerCase()} linked

\n

\n Link one or more {title.toLowerCase()} to this item\n

\n
\n {onLink && (\n \n )}\n
\n
\n \n ) : (\n \n \n
\n svg.chevron]:rotate-180\">\n

{title}

\n {!isLoading && !error && (\n \n {itemCount}\n \n )}\n \n
\n {hasItems && onLink && (\n \n )}\n
\n
\n \n \n {isLoading && (\n
\n \n \n
\n )}\n {!!error && (\n

Failed to load relation data.

\n )}\n {hasItems && (\n
\n \n \n \n {columnKeys.map((key) => (\n {getColumnTitle(key, columns)}\n ))}\n \n \n \n \n {items.map((item) => (\n onViewItem(item.id) : undefined}\n >\n {columnKeys.map((key) => (\n {formatCellValue(item.data[key])}\n ))}\n \n {onUnlink && (\n \n \n {\n e.stopPropagation();\n setUnlinkTarget({\n id: item.id,\n label: getItemLabel(item),\n });\n }}\n >\n \n Unlink\n \n \n Unlink\n \n )}\n \n \n ))}\n \n
\n
\n )}\n
\n
\n
\n )}\n
\n {unlinkDialog}\n
\n );\n}\n", "type": "registry:ui" }, { "path": "src/patterns/relation-section/index.ts", "content": "export { RelationSection } from \"./relation-section\";\nexport type { RelationSectionProps, RelationItem, RelationColumn } from \"./relation-section\";\n", "type": "registry:ui" } ], "type": "registry:ui" }