import React from "react"; import { ImageIcon } from "lucide-react"; import { cls } from "../../util"; import { Typography } from "../../components/Typography"; import { Chip } from "../../components/Chip"; import { BooleanSwitch } from "../../components/BooleanSwitch"; import { Markdown } from "../../components/Markdown"; import { Tooltip } from "../../components/Tooltip"; import { iconSize } from "../../icons"; import type { CellRendererProps, CollectionPropertyConfig, CollectionEnumValueConfig } from "./CollectionViewTypes"; // ——— Helpers ——— /** * Resolve an enum key to its display label and optional color. */ function resolveEnumLabel( enumValues: CollectionPropertyConfig["enum"], value: unknown ): { label: string; color?: string } | undefined { if (!enumValues) return undefined; const key = String(value); if (enumValues instanceof Map) { const config: CollectionEnumValueConfig | undefined = enumValues.get(value as string | number); if (!config) return undefined; if (typeof config === "string") return { label: config }; return { label: config.label, color: config.color }; } const config: CollectionEnumValueConfig | undefined = enumValues[key]; if (!config) return undefined; if (typeof config === "string") return { label: config }; return { label: config.label, color: config.color }; } /** * Format a date value using Intl.DateTimeFormat. */ function formatDate(value: unknown, includeTime: boolean): string { if (value == null) return "—"; const date = value instanceof Date ? value : new Date(String(value)); if (isNaN(date.getTime())) return String(value); const options: Intl.DateTimeFormatOptions = includeTime ? { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" } : { year: "numeric", month: "short", day: "numeric" }; return new Intl.DateTimeFormat(undefined, options).format(date); } /** * Type guard for geopoint-like objects. */ function isGeoPoint(value: unknown): value is { lat: number; lng: number } { if (typeof value !== "object" || value === null) return false; const obj = value as Record; return typeof obj.lat === "number" && typeof obj.lng === "number"; } // ——— Component ——— /** * Default cell renderer for the headless CollectionView. * Handles all built-in property types without depending on any Rebase core types. * * @group Preview components */ export function DefaultCellRenderer({ row, propertyKey, property, value, size, width, height }: CellRendererProps) { // 1. Null / undefined → em-dash if (value === null || value === undefined) { return ( ); } // 2. Custom Preview component override if (property.Preview) { const PreviewComponent = property.Preview; return ( ); } // ——— Type-specific renderers ——— switch (property.type) { case "string": return renderString(property, value); case "number": return renderNumber(property, value); case "boolean": return renderBoolean(value); case "date": return renderDate(property, value); case "array": return renderArray(property, value); case "map": return renderMap(value); case "reference": case "relation": return renderReference(value); case "geopoint": return renderGeopoint(value); default: return ( {JSON.stringify(value)} ); } } // ——— Per-type render functions ——— function renderString(property: CollectionPropertyConfig, value: unknown): React.ReactElement { const strValue = String(value); // Enum → Chip if (property.enum) { const resolved = resolveEnumLabel(property.enum, value); return ( {resolved?.label ?? strValue} ); } // Preview as tag → Chip if (property.previewAsTag) { return ( {strValue} ); } // URL → clickable link if (property.url) { const href = typeof property.url === "string" ? property.url : strValue; return ( {strValue} ); } // Email → mailto link if (property.email) { return ( {strValue} ); } // Markdown → rendered markdown if (property.markdown) { return (
); } // Storage → image preview with fallback if (property.storage) { return (
{strValue ? ( { const target = e.currentTarget; target.style.display = "none"; const fallback = target.nextElementSibling; if (fallback instanceof HTMLElement) { fallback.style.display = "flex"; } }} /> ) : null}
); } // Default string → truncated text return ( 50 ? strValue : undefined} side="bottom"> {strValue} ); } function renderNumber(property: CollectionPropertyConfig, value: unknown): React.ReactElement { const numStr = String(value); // Enum → Chip if (property.enum) { const resolved = resolveEnumLabel(property.enum, value); return ( {resolved?.label ?? numStr} ); } // Default → right-aligned return ( {numStr} ); } function renderBoolean(value: unknown): React.ReactElement { return (
); } function renderDate(property: CollectionPropertyConfig, value: unknown): React.ReactElement { const includeTime = property.mode === "date_time"; const formatted = formatDate(value, includeTime); return ( {formatted} ); } function renderArray(property: CollectionPropertyConfig, value: unknown): React.ReactElement { if (!Array.isArray(value)) { return ( ); } // Array of enum values → row of chips if (property.of?.enum && value.length > 0) { return (
{value.map((item, i) => { const resolved = resolveEnumLabel(property.of!.enum, item); return ( {resolved?.label ?? String(item)} ); })}
); } // Array of strings → comma-separated if (value.length > 0 && value.every((item) => typeof item === "string")) { return ( {(value as string[]).join(", ")} ); } // Generic array → item count return ( {value.length} {value.length === 1 ? "item" : "items"} ); } function renderMap(value: unknown): React.ReactElement { if (typeof value !== "object" || value === null) { return ( ); } const fieldCount = Object.keys(value as Record).length; return ( {fieldCount} {fieldCount === 1 ? "field" : "fields"} ); } function renderReference(value: unknown): React.ReactElement { return ( {String(value)} ); } function renderGeopoint(value: unknown): React.ReactElement { if (isGeoPoint(value)) { return ( {value.lat.toFixed(6)}, {value.lng.toFixed(6)} ); } return ( {String(value)} ); }