// DynamicRelations — metadata-driven panel list. Given a parent record and the // `TableMetadata.relations[]` the kernel serves (>= v0.41.0), it renders one // `` panel per relation. This is what a generic detail page // renders to surface "a Customer's vehicles, addresses, attachments" without // hand-wiring each child list. // // For every RelationMeta it: // - maps `kind` straight through (one_to_many | many_to_many), // - uses `through` as the child/pivot model and `foreign_key` as the FK, // - merges the relation's static `scope` (polymorphic discriminators, e.g. // { owner_model: "Customer" }) into the panel's `filters` so the child list // is scoped by the FK AND every scope column. import { useMemo } from 'react' import { cn } from '@asteby/metacore-ui/lib' import { DynamicRelation, type DynamicRelationStrings } from './dynamic-relation' import type { RelationMeta } from './types' export interface DynamicRelationsProps { /** * The parent record. Its `id` (or `parentIdKey`) seeds every child list's * foreign-key filter. Null/undefined → renders nothing (loading guard). */ record: { id?: string | number; [k: string]: unknown } | null | undefined /** The relations to render — typically `metadata.relations`. */ relations: RelationMeta[] | null | undefined /** * Which field of `record` holds the parent id. Default `'id'`. Lets a host * key relations off a non-`id` primary key. */ parentIdKey?: string /** Wrapper className for the whole stack. */ className?: string /** Per-panel wrapper className. */ panelClassName?: string /** * Permisos propagados a cada panel. Default true. A host can lock the whole * detail page read-only by passing canCreate/canDelete/canEdit = false. */ canCreate?: boolean canDelete?: boolean canEdit?: boolean /** Translatable strings forwarded to each DynamicRelation. */ strings?: Partial /** * True cuando estos paneles se renderizan como sub-tablas de líneas dentro * del MODAL de vista de un registro. Propaga `lineSubtable` a cada * `` para ocultar por defecto las columnas de auditoría/ * sistema redundantes bajo el padre. Default false — una página de detalle * autónoma conserva todas las columnas. */ lineSubtable?: boolean /** * Solo renderiza las relaciones de COMPOSICIÓN — las que el kernel marca * con `embed: true` (las líneas de un documento). Es lo que usan los MODALES * de registro: antes embebían TODAS las relaciones one_to_many del modelo, * así que abrir "Editar Almacén" arrastraba miles de existencias y traspasos * al formulario. Las relaciones no embebidas siguen accesibles desde su * propia página / la vista de detalle, que renderiza el listado completo. * * Default false: una página de detalle autónoma sigue mostrando todas. * Una relación sin `embed` (kernel viejo) NO se embebe — el gate falla del * lado seguro. */ embedOnly?: boolean /** Bubble up when any panel's data changes (create/delete/attach/detach). */ onChange?: (relation: RelationMeta) => void } /** * Normalizes the parent id off the record, tolerating a custom `parentIdKey`. * Returns `undefined` when unusable so callers can guard rendering. */ export function resolveParentId( record: { [k: string]: unknown } | null | undefined, parentIdKey = 'id', ): string | number | undefined { if (!record) return undefined const raw = record[parentIdKey] if (raw === undefined || raw === null || raw === '') return undefined if (typeof raw === 'number' || typeof raw === 'string') return raw return undefined } /** * Merges a relation's static `scope` with its foreign-key entry into the flat * `filters` map `` expects. The FK is included so a panel that * only consumes `filters` (rather than the dedicated `foreignKey` prop) stays * correctly scoped; `` already de-dups the FK so passing it in * both places is safe. */ export function buildRelationFilters( relation: Pick, parentId: string | number, ): Record { const out: Record = {} if (relation.scope) { for (const [k, v] of Object.entries(relation.scope)) { if (!k || v === undefined || v === null) continue out[k] = String(v) } } if (relation.foreign_key) out[relation.foreign_key] = String(parentId) return out } /** * ¿La relación es de COMPOSICIÓN (embebible en un modal)? Solo `embed: true` * califica: la ausencia del flag — un kernel viejo que todavía no lo sirve — * significa NO embeber, que es el lado seguro (el costo de un falso negativo * es un panel de menos en el modal; el de un falso positivo, miles de filas * dentro de un formulario). */ export function isEmbedded(rel: Pick): boolean { return rel.embed === true } /** Stable React key for a relation panel. */ function relationKey(rel: RelationMeta, idx: number): string { return rel.name || `${rel.through}-${rel.foreign_key}-${idx}` } export function DynamicRelations({ record, relations, parentIdKey = 'id', className, panelClassName, canCreate = true, canDelete = true, canEdit = true, strings, lineSubtable = false, embedOnly = false, onChange, }: DynamicRelationsProps) { const parentId = useMemo( () => resolveParentId(record, parentIdKey), [record, parentIdKey], ) // Gate de composición: en un modal solo entran las relaciones marcadas // `embed` por el kernel. Fuera del modal la lista pasa entera. const visible = useMemo( () => (embedOnly ? (relations || []).filter(isEmbedded) : relations || []), [relations, embedOnly], ) if (parentId === undefined || visible.length === 0) { return null } return ( // `space-y-6` separates stacked relation panels (e.g. "Líneas del // pedido" and "Facturas" in the view modal) — without it consecutive // panels sat flush against each other with no breathing room.
{visible.map((rel, idx) => { const filters = buildRelationFilters(rel, parentId) const panelStrings: Partial = { ...(strings || {}), ...(rel.label ? { title: rel.label } : {}), } // A relation flagged read-only in the kernel metadata forces the // panel's mutation controls off regardless of the host perms. // Tolerates the camelCase alias. const relReadonly = rel.readonly === true || rel.readOnly === true if (rel.kind === 'many_to_many') { return ( onChange(rel) : undefined} /> ) } return ( onChange(rel) : undefined} /> ) })}
) }