// Default `getDynamicColumns` factory used by hosts that don't need a custom // renderer. Supports every cell type produced by kernel/dynamic metadata: // badge (static + endpoint-loaded options), avatar/search, creator/user, // phone, date, boolean, relation-badge-list, media-gallery, image, plus the // declarative pro renderers url/link, email, currency, number, percent/ // progress, status, tags, color, code/truncate-text, relation (resolved FK // chip), option/select badges, and a generic text fallback. The renderer // resolves `cellStyle ?? type` for each column. // // The implementation was previously duplicated across multiple host apps // (~550 LOC each, drifting). It now lives here so a single fix propagates // to every host. Hosts inject app-specific URL helpers via the `helpers` // argument so the SDK stays free of environment-bound code. import * as React from 'react' import { ColumnDef } from '@tanstack/react-table' import { format, type Locale } from 'date-fns' import { es, enUS } from 'date-fns/locale' import * as icons from 'lucide-react' import { MoreHorizontal } from 'lucide-react' import { Avatar, AvatarFallback, AvatarImage, Badge, Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, InitialsAvatar, } from '@asteby/metacore-ui' import { DataTableColumnHeader, FilterableColumnHeader, type ColumnFilterMeta, } from '@asteby/metacore-ui/data-table' import { generateBadgeStyles, getInitials, relationChipStyles, } from '@asteby/metacore-ui/lib' import { Progress } from './dialogs/_primitives' import { humanizeToken, translateMetadataLabel } from './dynamic-columns-helpers' import { objectLabel } from './dynamic-relation-helpers' import { OptionBadge, RelationThumbnail, ImageStack, statusColorFor, useIsDarkTheme, } from './display-value' import { MediaValue } from './rich-url' import { OptionsContext } from './options-context' import { DynamicIcon, isLucideIconName } from './dynamic-icon' import { CollectionCell } from './collection-cell' import { isNilUuid, normalizeNilUuid } from './nil-uuid' import { useOptionsResolver } from './use-options-resolver' import type { TableMetadata, ColumnDefinition } from './types' import { isColumnVisibleInTable } from './column-visibility' import type { ColumnFilterConfig, GetDynamicColumns, } from './dynamic-columns-shim' /** Host-supplied helpers consumed by avatar/image cell renderers. */ export interface DynamicColumnsHelpers { /** * Resolves a relative or absolute media path into a renderable URL. Hosts * typically prepend their CDN/storage base. If omitted, paths are passed * through verbatim. */ getImageUrl?: (path: string) => string /** * API origin used to build avatar URLs when the row carries a bare filename * instead of an absolute URL or sibling `.avatar` field. Usually * `import.meta.env.VITE_API_URL.replace('/api', '')`. */ apiBaseUrl?: string } const defaultGetImageUrl = (path: string) => path const getNestedValue = (obj: any, path: string) => path.split('.').reduce((acc, part) => acc && acc[part], obj) /** * Reads a styleConfig key tolerating both snake_case (emitted by the kernel) * and camelCase (sometimes produced by compiled models). Returns the first * defined match, e.g. `cfg('label_field', 'labelField')`. */ const styleCfg = ( col: ColumnDefinition, ...keys: string[] ): any => { const cfg = col.styleConfig if (!cfg) return undefined for (const k of keys) { if (cfg[k] !== undefined && cfg[k] !== null) return cfg[k] } return undefined } const EmptyCell = () => - /** * Resolves the active currency for a column: the column's explicit currency * style wins, then the org-level fallback (org config, like `timeZone`), then * 'USD' as a last resort. */ export const resolveCurrency = (col: ColumnDefinition, orgCurrency?: string): string => styleCfg(col, 'currency') || orgCurrency || 'USD' const formatNumber = ( value: number, opts: Intl.NumberFormatOptions, locale?: string, ) => new Intl.NumberFormat(locale || undefined, opts).format(value) /** * Reads the column's footer-aggregate opt-in. A column opts into the table * footer total via its manifest `display_config.aggregate` (mapped by the * kernel to `styleConfig.aggregate` at runtime). Returns the aggregate kind * (e.g. `'sum'`) or undefined when the column carries no footer total. */ export const aggregateOf = (col: ColumnDefinition): string | undefined => { const v = styleCfg(col, 'aggregate') return typeof v === 'string' && v !== '' ? v : undefined } /** * Formats a footer aggregate total with the SAME rules the body cells use: * currency columns render as the org currency (resolveCurrency), number * columns honour `styleConfig.decimals`, everything else falls back to a * locale-formatted number. Non-numeric/empty totals render as a dash so an * empty filtered set reads cleanly. */ export const formatAggregateTotal = ( col: ColumnDefinition, value: unknown, currency?: string, locale?: string, ): string => { const num = typeof value === 'number' ? value : Number(value) if (value === null || value === undefined || isNaN(num)) return '—' const renderAs = col.cellStyle ?? col.type if (renderAs === 'currency') { const decimals = styleCfg(col, 'decimals') ?? 2 return formatNumber( num, { style: 'currency', currency: resolveCurrency(col, currency), minimumFractionDigits: decimals, maximumFractionDigits: decimals, }, locale, ) } const decimals = styleCfg(col, 'decimals') return formatNumber( num, decimals !== undefined ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : {}, locale, ) } /** Copyable monospaced text cell (code/IDs/hashes). */ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLength }) => { const [copied, setCopied] = React.useState(false) const display = maxLength && text.length > maxLength ? `${text.slice(0, maxLength)}…` : text const onCopy = () => { try { navigator.clipboard?.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 1200) } catch { /* clipboard unavailable */ } } return (
{display}
) } /** * Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles, * …) and fall back to `state` (purchases, inventory transfers, …). Empty string * is treated as missing so a blank `status` does not hide a populated `state`. */ const rowLifecycleState = (row: any): unknown => { const status = row?.status if (status !== undefined && status !== null && status !== '') return status const state = row?.state if (state !== undefined && state !== null && state !== '') return state return undefined } /** * State-machine gate for per-row actions. * * An action that declares a non-empty `requiresState` (camelCase) / `requires_state` * (snake_case, as served by some backends) is only surfaced for rows whose * lifecycle field (`status` or `state`) is contained in that array. This hides * e.g. "Recibir" (requiresState: ['confirmed','partial']) on a purchase order * still in `draft`. * * Null-safe & non-regressive: * - action without requiresState (or empty array) → always shown. * - row with neither `status` nor `state` → all actions shown. */ export const isActionAllowedForRowState = (action: any, row: any): boolean => { const requires: unknown = action?.requiresState ?? action?.requires_state if (!Array.isArray(requires) || requires.length === 0) return true const status = rowLifecycleState(row) if (status === undefined) return true return requires.map(String).includes(String(status)) } /** * Declarative `condition` gate for a per-row action: shows the action only when * the row's `field` satisfies the operator. Supports both the SDK dialect * (`eq` | `neq` | `in` | `not_in`) and the common host dialect * (`equals` | `notEquals` | `not_in`), plus the truthy/falsy family (same * operator set as the host's document print gate — services/document_gate.go * — kept in sync so a manifest author doesn't have to know which gate a given * contribution goes through). Nested paths (`user.verified`) are resolved via * `getNestedValue`. No condition → always shown. * * `default: return true` for a genuinely unknown operator is deliberate — an * addon shipped against a newer SDK than the host runs should degrade to * "always show" (worst case: an extra menu item), never to "always hide" * (worst case: a feature silently vanishes). That same permissiveness is why * `truthy`/`falsy` going unrecognized here was a real, silent bug rather * than a build error: confirmed live — a `condition: {field: "amount_due", * operator: "truthy"}` row action rendered on every row regardless of * amount_due, because the switch fell through to the default and nothing * ever signaled it wasn't actually gating anything. */ export const isActionConditionMet = (action: any, row: any): boolean => { if (!action?.condition) return true const { field, operator, value } = action.condition if (!field) return true const coerce = (v: unknown): string => { if (v === null || v === undefined) return '' if (typeof v === 'boolean') return v ? 'true' : 'false' return String(v) } const raw = getNestedValue(row, field) const rowValue = coerce(raw) const values = (Array.isArray(value) ? value : [value]).map(coerce) const op = String(operator ?? '').toLowerCase() switch (op) { case 'eq': case 'equals': case '==': return rowValue === values[0] case 'neq': case 'notequals': case 'not_equals': case '!=': return rowValue !== values[0] case 'in': return values.includes(rowValue) case 'not_in': case 'notin': return !values.includes(rowValue) case 'truthy': case 'present': case 'set': return rowValue !== '' && rowValue !== 'false' && rowValue !== '0' case 'falsy': case 'blank': case 'empty': return rowValue === '' || rowValue === 'false' || rowValue === '0' default: return true } } /** * Whether a per-row action should appear for `row`: both the state-machine gate * (`requiresState`) AND the declarative `condition` must pass. Shared by the * table's action column and the kanban card menu so they hide/show identically. */ export const isRowActionVisible = (action: any, row: any): boolean => isActionAllowedForRowState(action, row) && isActionConditionMet(action, row) const lowerFirst = (value?: string) => { if (!value) return value return value.charAt(0).toLowerCase() + value.slice(1) } const getPathVariants = (path?: string) => { if (!path) return [] const normalized = path .split('.') .map((segment) => lowerFirst(segment) || segment) .join('.') return Array.from(new Set([path, normalized])).filter(Boolean) } const getValueFromPathVariants = (obj: any, path?: string) => { if (!path) return undefined for (const candidate of getPathVariants(path)) { const value = getNestedValue(obj, candidate as string) if (value !== undefined && value !== null) return value } return undefined } const renderRelationBadges = (items: any, col: ColumnDefinition) => { if (!Array.isArray(items) || items.length === 0) { return - } return (
{items.map((item: any, idx: number) => { const relationTarget = col.relationPath ? getValueFromPathVariants(item, col.relationPath) ?? item : item const displaySource = relationTarget ?? item let displayValue = col.displayField !== undefined && col.displayField !== null ? getValueFromPathVariants(displaySource, col.displayField) : displaySource if (displayValue === undefined || displayValue === null) { displayValue = displaySource } const label = displayValue !== undefined && displayValue !== null ? String(displayValue) : '-' let iconValue: string | undefined if (col.iconField) { const rawIcon = getValueFromPathVariants(displaySource, col.iconField) if (rawIcon !== undefined && rawIcon !== null) { iconValue = String(rawIcon) } } return ( {iconValue && ( )} {label} ) })}
) } /** * Read-side counterpart of `DynamicMultiSelectField` (dynamic-multi-select- * field.tsx): a `ref` column whose value is a plain jsonb array of target ids * (field.multiple:true at write time) rather than the single-FK sibling * `{value,label}` object `RelationCell` expects. There is no per-row backend * resolution for this shape, so this cell resolves labels itself — one * `useOptionsResolver` page (id → label) shared across every row of the * column via `OptionsContext` — and renders each id as a badge. */ const RelationIdListCell: React.FC<{ ids: string[]; ref: string }> = ({ ids, ref: relTarget }) => { const { options } = useOptionsResolver({ modelKey: '', fieldKey: 'id', ref: relTarget, limit: 200 }) if (ids.length === 0) return return (
{ids.map((id) => { const opt = options.find((o) => String(o.id) === String(id)) return ( {opt ? opt.label : id.slice(0, 8)} ) })}
) } const BadgeWithEndpointOptions: React.FC<{ endpoint: string value: any getImageUrl?: (path: string) => string }> = ({ endpoint, value, getImageUrl }) => { const { optionsMap } = React.useContext(OptionsContext) const options = optionsMap.get(endpoint) || [] const option = options.find((opt: any) => opt.value === value) // Reference options carry the backend-projected `description` (the SKU/email // the author pointed the options config at) — surface it as the chip subtitle // so a resolved record reads "Name / SKU" instead of a bare name. if (option) return // No declared option matched → humanize the raw token as a safety net so a // cell never shows `in_progress` verbatim (option.label still wins above). return {humanizeToken(value)} } /** * Resolves the relation sibling object a backend serves alongside an FK column. * For a column keyed `category_id` the data row also carries * `row.category = { value, label }` (the FK key with the trailing `_id` * stripped) — mirroring how `created_by` ships as a `{ name, avatar, email }` * sibling consumed by the `creator` renderer. Returns the relation key so the * cell can read `row[relationKeyFor(col)]`. */ export const relationKeyFor = (col: Pick): string => { const k = col.key return k.endsWith('_id') ? k.slice(0, -3) : k } /** Cell renderers (`cellStyle`/`type`) that resolve to the date renderer. */ export const DATE_CELL_TYPES = ['date', 'datetime', 'timestamp', 'timestamptz'] as const /** * Pure formatter behind the date/datetime cell. Returns the display string and * an optional full-precision `title` (tooltip), or `null` when the value is * empty/invalid/the Go zero-time so the cell renders an em-dash. * - `date`: day only (`PPP`), no tooltip. * - `datetime`/`timestamp(tz)`: day + time (`Pp`) with a full-precision * tooltip (`PPpp`) — the 7Leguas pattern. * * When a `timeZone` (IANA, e.g. the org's `America/Mexico_City`) is provided, * instants are rendered in that zone via the native `Intl.DateTimeFormat` so * the displayed day/time never shifts with the viewer's browser timezone: * - instant (datetime/timestamp(tz)): `dateStyle:'medium' timeStyle:'short'` * in the org zone, with a `dateStyle:'long' timeStyle:'medium'` + * `timeZoneName:'short'` tooltip. * - `date` (pure calendar day): rendered pinned to UTC so it never rolls to * the previous/next day, no tooltip. * Without a `timeZone`, the exact date-fns behavior is preserved (back-compat). */ export function formatDateCell( value: unknown, renderAs: string | undefined, locale: Locale, timeZone?: string, ): { display: string; title?: string } | null { if (value === null || value === undefined || value === '') return null const date = new Date(value as any) if (isNaN(date.getTime()) || date.getFullYear() <= 1) return null const withTime = renderAs !== 'date' if (timeZone) { // `locale.code` is the BCP-47 tag date-fns ships (e.g. 'es', 'en-US'). const localeTag = locale?.code || undefined if (withTime) { return { display: new Intl.DateTimeFormat(localeTag, { timeZone, dateStyle: 'medium', timeStyle: 'short', }).format(date), // `dateStyle`/`timeStyle` can't be combined with explicit // component options like `timeZoneName`, so spell the tooltip // out: long date + seconds + the zone abbreviation. title: new Intl.DateTimeFormat(localeTag, { timeZone, year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short', }).format(date), } } // Pure calendar date: pin to UTC so it never shifts across zones. return { display: new Intl.DateTimeFormat(localeTag, { timeZone: 'UTC', dateStyle: 'long', }).format(date), } } if (withTime) { return { display: format(date, 'Pp', { locale }), title: format(date, 'PPpp', { locale }), } } return { display: format(date, 'PPP', { locale }) } } /** * Reads the resolved relation/option label a backend serves for an FK or * option column, falling back to the raw value. Pure so the cell renderers and * tests share one resolution path: * - relation: prefer the sibling `{ value, label }` object's label. * - option: prefer the matched `options[].label` (value compared as string). * - else: the raw value coerced to string ('' when nullish). */ export const resolveRelationLabel = (col: ColumnDefinition, row: any): string => { const sibling = getNestedValue(row, relationKeyFor(col)) const label = sibling && typeof sibling === 'object' ? sibling.label ?? sibling.name : undefined if (label !== undefined && label !== null && label !== '') return String(label) const raw = getNestedValue(row, col.key) // An unresolved FK that arrived as the nil UUID reads as empty, not zeros. if (raw === undefined || raw === null || isNilUuid(raw)) return '' return String(raw) } /** * Reads the thumbnail URL a backend serves on a resolved FK sibling, when * present. The backend stamps `image` onto the `{ value, label }` relation * object when the referenced model carries an image column (brand logo, * product photo, customer avatar). Returns '' when there is no sibling image — * the chip then renders text-only, exactly as before. */ export const resolveRelationImage = (col: ColumnDefinition, row: any): string => { const sibling = getNestedValue(row, relationKeyFor(col)) if (sibling && typeof sibling === 'object') { const img = sibling.image ?? sibling.avatar ?? sibling.photo ?? sibling.logo ?? sibling.thumbnail if (img !== undefined && img !== null && img !== '') return String(img) } return '' } /** * Label when an actor cell has no resolved name. `creator` cells and the * auto-injected `created_by.*` avatar column (hosts ship it as `type: avatar` * with `tooltip: created_by.name`) mean "system-created" → "Sistema". Other * avatar/user/search empties stay "N/A" (unassigned person). */ export function resolveMissingActorLabel( renderAs: string | undefined, colKey: string | undefined, namePath: string | undefined, t?: (key: string, options?: { defaultValue?: string }) => string, ): string { const path = namePath || '' const key = colKey || '' const isCreatedByColumn = key === 'created_by' || key === 'created_by_id' || key.startsWith('created_by.') || path === 'created_by' || path === 'created_by.name' || path.startsWith('created_by.') if (renderAs === 'creator' || isCreatedByColumn) { return t ? t('common.system', { defaultValue: 'Sistema' }) : 'Sistema' } return 'N/A' } /** * Coerces a creator/user cell value into a display string. Backends often put * the whole `{name,avatar,email}` sibling at `created_by` (column key = * namePath), and `String(object)` leaked as `[object Object]` in the table. * Prefer `objectLabel`, then scalars; empty → undefined so the caller can fall * back to Sistema / N/A. */ export function resolveActorDisplayName(raw: unknown): string | undefined { if (raw === undefined || raw === null || raw === '') return undefined if (typeof raw === 'object') { return objectLabel(raw) } const s = String(raw).trim() return s === '' || s === '[object Object]' ? undefined : s } /** * Resolves the image source for `avatar`/`search`/`creator`/`user` cells. * Priority: sibling `.avatar`/`.photo` next to a nested key (`user.name` → * `user.avatar`), then the cell's own value. Bare filenames (backends often * store just `"2.png"`) are prefixed with `apiBaseUrl` + the column's declared * basePath (`styleConfig.base_path` or `col.basePath`). * * Contract (applies to both branches, so hosts get one predictable rule): * 1. absolute `http(s)://` URL → untouched * 2. rooted `/path` → untouched; the host's `getImageUrl` prepends its origin * 3. bare filename → `apiBaseUrl + basePath + filename` * Previously the sibling branch returned bare filenames untouched (broken for * any backend that stores just `"2.png"` next to a nested key) and the value * branch injected basePath into already-rooted paths (junk URLs). */ export const resolveAvatarSrc = ( col: ColumnDefinition, row: any, value: any, apiBaseUrl = '', ): string | undefined => { let raw: string | undefined if (col.key.includes('.')) { const parentPath = col.key.split('.').slice(0, -1).join('.') const sibling = getNestedValue(row, `${parentPath}.avatar`) || getNestedValue(row, `${parentPath}.photo`) if (sibling) raw = String(sibling) } if (!raw && value !== undefined && value !== null && value !== '') raw = String(value) if (!raw) return undefined if (raw.startsWith('http') || raw.startsWith('/')) return raw const basePath = styleCfg(col, 'base_path', 'basePath') ?? col.basePath ?? '' return `${apiBaseUrl}${basePath}${raw}` } /** * Reads a secondary identifier the backend stamps on a resolved FK sibling — a * product's SKU, a user's email — projected as `subtitle`/`description` (the * relational twin of `image` via the column's `label_description`). Rendered * muted under the label so a reference chip reads "Name / SKU". Domain-agnostic: * only the generic `subtitle`/`description` keys are read (never a hardcoded * `sku`/`code`), so the author picks the column declaratively. '' when absent. */ export const resolveRelationSubtitle = (col: ColumnDefinition, row: any): string => { const sibling = getNestedValue(row, relationKeyFor(col)) if (sibling && typeof sibling === 'object') { const sub = sibling.subtitle ?? sibling.description if (sub !== undefined && sub !== null && sub !== '') return String(sub) } return '' } /** * Renders a resolved FK relation as a clean, truncated chip. Reads the * backend-resolved sibling `{ value, label[, image] }` (see `relationKeyFor`) * and shows its `label`, prefixed with a small thumbnail when the sibling * carries an `image`. Falls back to the raw id when no sibling was resolved, and * to an empty marker when there is no value at all. Domain-agnostic: works for * every `belongs_to` column (category, supplier, brand, …) without per-addon code. * * When `stack` is true (column `display: "image_stack"`), the landscape mark * sits ON TOP of the label — wide logos fit without cropping and the cell * stays readable in dense tables. */ const RelationCell: React.FC<{ col: ColumnDefinition row: any getImageUrl?: (path: string) => string /** Landscape stack: image above, text below (display: image_stack). */ stack?: boolean }> = ({ col, row, getImageUrl, stack = false }) => { const display = resolveRelationLabel(col, row) if (!display) return const image = resolveRelationImage(col, row) const subtitle = resolveRelationSubtitle(col, row) if (stack) { return ( ) } // FLAT reference cell: no tinted capsule around the pair. A reference is // data, not a status — the pill treatment (and its per-label tint) made a // products/warehouses listing read as a wall of badges. What identifies the // record is the ROUNDED-SQUARE thumb (the record's image, or the neutral // initials fallback) next to plain text; enum/status badges keep their // colored pill so the two vocabularies stay distinct. return ( {image ? ( ) : ( )} {subtitle ? ( {display} {subtitle} ) : ( {display} )} ) } /** * Renders a SAP-style polymorphic source-document reference as a navigable * chip. Reads the backend-resolved sibling `row[] = * { value, label, kind, table }` (see `relationKeyFor`) — the discriminator * (`source_kind`) selects the target model and the backend stamps the resolved * SQL `table` so the cell can link to `/m//` (the host router * handles `/m/:model/:id`). Shows the resolved `label` when present, else a * short id (first 8 chars of the value). Domain-agnostic: any polymorphic FK * (`source_id`, `document_id`, …) carrying `display: "reference"` works without * per-addon code. Mirrors `RelationCell`'s chip look (subtle tint, dark-mode * aware) so references read consistently next to plain relations. */ const ReferenceCell: React.FC<{ col: ColumnDefinition row: any }> = ({ col, row }) => { const isDark = useIsDarkTheme() const sibling = getNestedValue(row, relationKeyFor(col)) const value = (sibling && typeof sibling === 'object' ? sibling.value : undefined) ?? getNestedValue(row, col.key) if (value === undefined || value === null || value === '' || isNilUuid(value)) { return } const label = sibling && typeof sibling === 'object' ? sibling.label : undefined const kind = sibling && typeof sibling === 'object' ? sibling.kind : undefined const table = sibling && typeof sibling === 'object' ? sibling.table : undefined const displayText = label !== undefined && label !== null && label !== '' ? String(label) : `${String(value).slice(0, 8)}` // Tint keyed on the discriminator (when present) so e.g. sale/transfer/ // adjustment chips read as visually distinct families; falls back to the // display text. Same subtle relation-chip look + dark-mode handling. const chipStyles = relationChipStyles(String(kind || displayText), { isDark }) const className = 'inline-flex max-w-[220px] items-center gap-1 rounded-md px-2 py-0.5 text-sm font-medium' if (table && value) { return ( e.stopPropagation()} className={`${className} hover:underline`} style={chipStyles} title={displayText} > {displayText} ) } return ( {displayText} ) } /** * Generic avatar-style cell: round/rounded photo (or initials fallback) + * primary name + optional subtitle. Backs the `avatar`/`search` columns as * well as the `creator`/`user` cellStyles. Paths are parameterised so the same * JSX serves every variant. */ const AvatarCell: React.FC<{ name: string desc?: string avatarSrc?: string getImageUrl: (path: string) => string }> = ({ name, desc, avatarSrc, getImageUrl }) => (
{getInitials(name)}
{name} {desc && ( {desc} )}
) /** * Builds the canonical column factory used by `` when the host * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar * URL resolution. */ /** * `image`-type cell body. A value that is a lucide icon name (PascalCase or * kebab slug, e.g. "Banknote" / "credit-card") — the convention the `icon` * form widget stores — renders the glyph instead of an that would 404 * into an empty grey box. Exported for tests. */ export const ImageCell: React.FC<{ value: unknown getImageUrl: (path: string) => string /** Optional caption under the image (display: image_stack). */ label?: string stack?: boolean }> = ({ value, getImageUrl, label, stack = false }) => { if (!value && !label) return - if (value && isLucideIconName(value)) { return (
) } if (stack) { return ( ) } if (!value) return - return (
Thumbnail { ;(e.currentTarget as HTMLImageElement).style.display = 'none' }} />
) } export function makeDefaultGetDynamicColumns( helpers: DynamicColumnsHelpers = {}, ): GetDynamicColumns { const getImageUrl = helpers.getImageUrl ?? defaultGetImageUrl const apiBaseUrl = helpers.apiBaseUrl ?? '' return function defaultGetDynamicColumns( metadata: TableMetadata, onAction?: (action: string, row: any) => void, t?: (key: string, options?: any) => string, currentLanguage?: string, filterConfigs?: Map, timeZone?: string, currency?: string, ): ColumnDef[] { const dateLocale = currentLanguage === 'en' ? enUS : es const columns: ColumnDef[] = [ { id: 'select', header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" className="translate-y-[2px]" /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label="Select row" className="translate-y-[2px]" /> ), enableSorting: false, enableHiding: false, }, ] metadata.columns.forEach((col) => { // Honors both the legacy `hidden` boolean and the kernel's // `visibility` scope (skips `'modal'` and `'list'`). if (!isColumnVisibleInTable(col)) return const translatedLabel = translateMetadataLabel(col.label, t) const filterConfig = filterConfigs?.get(col.key) const columnMeta: Record = { label: translatedLabel, } if (filterConfig) { const fm: ColumnFilterMeta = { filterable: true, filterType: filterConfig.filterType as ColumnFilterMeta['filterType'], filterKey: filterConfig.filterKey, filterOptions: filterConfig.options, filterLoading: filterConfig.loading, filterSearchEndpoint: filterConfig.searchEndpoint, selectedValues: filterConfig.selectedValues, onFilterChange: filterConfig.onFilterChange, loadOptions: filterConfig.loadOptions, } Object.assign(columnMeta, fm) } columns.push({ accessorKey: col.key, id: col.key, meta: columnMeta, header: ({ column }) => filterConfig ? ( ) : ( ), cell: ({ row }) => { // Treat the nil UUID (unset nullable FK serialized as // all-zeros) as no value, so every type below hits its // existing empty branch instead of printing the zeros. const value = normalizeNilUuid(getNestedValue(row.original, col.key)) // Kernel emits the renderer flag as `type`; older hosts used // `cellStyle`. Accept both so a single backend works across // SDK versions. const renderAs = col.cellStyle ?? col.type // Endpoint-loaded badge options (preloaded into OptionsContext) if (renderAs === 'badge' && col.useOptions && col.searchEndpoint) { if (!value) return - return } // Static badge options — map value → label/icon/color if (renderAs === 'badge' && col.options && col.options.length > 0) { if (!value && value !== 0) return - const option = col.options.find((o) => o.value === String(value)) if (option) return return {humanizeToken(value)} } if (renderAs === 'relation-badge-list') { return renderRelationBadges(value, col) } // Generic badge (no options/endpoint) — still pill it, and // humanize raw enum tokens (no option exists to localize it). if (renderAs === 'badge') { if (!value && value !== 0) return return {humanizeToken(value)} } // Status — semantic color by value, options color wins. if (renderAs === 'status') { if (!value && value !== 0) return const sv = String(value) const option = col.options?.find((o) => o.value === sv) if (option) return const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') const styles = generateBadgeStyles(statusColorFor(sv), { isDark }) // No declared option → humanize the status token so // `in_progress` reads as "In Progress" instead of raw. return ( {humanizeToken(sv)} ) } // Polymorphic source-document reference (SAP-style). Reads // the backend-resolved `{ value, label, kind, table }` // sibling and renders a navigable `/m/
/` chip. // Checked before the relation branch so a polymorphic FK // carrying a `ref` still routes here. if (renderAs === 'reference') { return } // Landscape stack: wide image ON TOP, label UNDERNEATH. // Declared via `display: "image_stack"` on an image column // or on a belongs_to FK whose sibling carries a logo/photo // (brand marks, product cards). Fits logos that are wider // than tall without cropping into a square thumb. if (renderAs === 'image_stack') { // FK relation (brand_id → brands) OR any column that // already resolved a sibling with an image — stack it. // Don't require `col.ref` alone: enrichment sometimes // leaves type=text while cellStyle carries image_stack. const looksRelation = !!col.ref || (typeof col.key === 'string' && col.key.endsWith('_id') && resolveRelationLabel(col, row.original) != null) if (looksRelation) { return ( ) } const labelField = styleCfg(col, 'label_field', 'labelField') const caption = labelField ? String(getNestedValue(row.original, labelField) ?? '') : undefined return ( ) } // Resolved FK relation chip. Triggers on an explicit // `cellStyle: 'relation'` or on any column carrying a `ref` // (a belongs_to FK) that isn't being rendered as an // option/badge. Reads the backend-resolved // `row[] = { value, label }` sibling. if ( renderAs === 'relation' || (col.ref && !col.options?.length && renderAs !== 'badge' && renderAs !== 'status') ) { // A `ref` column backed by a jsonb array (field.multiple: // true at write time — see DynamicMultiSelectField) has no // resolved sibling object; RelationCell expects one. Route // it to the id-list cell instead. if (Array.isArray(value)) { return } return } // Option/type column: a `select`-style column ships its // localized `options: [{value,label,color,icon}]` inline and // the cell value is the raw option value (e.g. "storable"). // Render the matched option's label as a colored badge — // same OptionBadge the `badge`/`status` cells use. if ( (renderAs === 'select' || renderAs === 'option' || col.type === 'select') && col.options && col.options.length > 0 ) { if (!value && value !== 0) return const option = col.options.find((o) => o.value === String(value)) if (option) return return {humanizeToken(value)} } switch (renderAs) { case 'date': case 'datetime': case 'timestamp': case 'timestamptz': { const formatted = formatDateCell(value, renderAs, dateLocale, timeZone) if (!formatted) return - return (
{formatted.display}
) } case 'search': case 'avatar': case 'creator': case 'user': { // `creator`/`user` resolve the name from an explicit // styleConfig.name_field first, then the legacy // tooltip/displayField hints, then the column key. const namePath = styleCfg(col, 'name_field', 'nameField') || col.tooltip || col.displayField || col.key // A creator / created_by cell with no resolved actor // means the record was created by the SYSTEM (seed / // event / host auto-inject with null created_by_id), // not a missing value — show "Sistema" rather than // "N/A". Hosts often inject the column as // `type: avatar` + `key: created_by.avatar` + // `tooltip: created_by.name`, so renderAs is `avatar` // even though the semantic is creator. Plain // user/avatar/search columns (unassigned person) // still keep "N/A". const resolvedName = resolveActorDisplayName( getNestedValue(row.original, namePath), ) const name = resolvedName || resolveMissingActorLabel(renderAs, col.key, namePath, t) const desc = getNestedValue(row.original, col.description || '') const avatarSrc = resolveAvatarSrc(col, row.original, value, apiBaseUrl) return ( ) } case 'relation-badge-list': return renderRelationBadges(value, col) case 'url': case 'link': { const labelField = styleCfg(col, 'label_field', 'labelField') const urlField = styleCfg(col, 'url_field', 'urlField') const rawUrl = urlField ? getNestedValue(row.original, urlField) : value if (!rawUrl) return const urlStr = String(rawUrl) // Explicit label from a `label_field` wins; otherwise // the shared primitive derives a smart label (hostname // / file name). Images render as a small (~h-8) inline // thumbnail, files as a chip, else a link chip. Same // renderer as the detail dialog. const label = labelField ? String(getNestedValue(row.original, labelField) ?? '') : undefined const iconName = styleCfg(col, 'icon') return ( ) } case 'email': { if (!value) return const email = String(value) return ( e.stopPropagation()} > {email} ) } case 'currency': { const num = typeof value === 'number' ? value : Number(value) if (value === null || value === undefined || isNaN(num)) return (
) const decimals = styleCfg(col, 'decimals') ?? 2 // Per-row currency: `display_config.currency_field` // names a sibling column carrying the ISO code of // THIS row's amount (multi-currency tables: a Bs // tender must not render as the org's USD). Falls // back to the column/org currency; if the code is // not a valid ISO 4217 code, prefix it verbatim. const currencyField = styleCfg(col, 'currency_field') const rowCode = typeof currencyField === 'string' && currencyField ? String( getNestedValue(row.original, currencyField) ?? '', ).trim() : '' const activeCode = rowCode || resolveCurrency(col, currency) let formatted: string try { formatted = formatNumber( num, { style: 'currency', currency: activeCode, minimumFractionDigits: decimals, maximumFractionDigits: decimals, }, currentLanguage, ) } catch { // Non-ISO code (e.g. a custom tender label): // render " 1,234.56" instead of crashing. formatted = `${activeCode} ${formatNumber( num, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }, currentLanguage, )}` } return ( {formatted} ) } case 'number': { const num = typeof value === 'number' ? value : Number(value) if (value === null || value === undefined || isNaN(num)) return (
) const decimals = styleCfg(col, 'decimals') return ( {formatNumber( num, decimals !== undefined ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals, } : {}, currentLanguage, )} ) } case 'percent': case 'progress': { const num = typeof value === 'number' ? value : Number(value) if (value === null || value === undefined || isNaN(num)) return const pct = Math.max(0, Math.min(100, num)) return (
{Math.round(pct)}%
) } case 'tags': { const list: string[] = Array.isArray(value) ? value.map(String) : value ? String(value) .split(',') .map((s) => s.trim()) .filter(Boolean) : [] if (list.length === 0) return return (
{list.map((tag, i) => ( {tag} ))}
) } case 'color': { if (!value) return const hex = String(value) return (
{hex}
) } case 'code': case 'truncate-text': { if (value === null || value === undefined || value === '') return const maxLength = styleCfg(col, 'max_length', 'maxLength') return } case 'phone': { if (!value) return - return {String(value)} } case 'boolean': { const showText = styleCfg(col, 'show_text', 'showText') !== false return ( {value ? ( ) : ( )} {showText && ( {value ? 'Sí' : 'No'} )} ) } case 'media-gallery': { if (!value || (Array.isArray(value) && value.length === 0)) { return - } const mediaItems = Array.isArray(value) ? value : [] const visibleItems = mediaItems.slice(0, 3) const remaining = mediaItems.length - 3 return (
{visibleItems.map((item: any, i: number) => { const src = item.url if (item.type === 'image') { return ( {item.type?.[0]} ) } return (
) })} {remaining > 0 && (
+{remaining}
)}
) } case 'image': { const imageValue = value || (Array.isArray(row.original.media) ? row.original.media.find((m: any) => m.type === 'image')?.url : null) return } case 'image_stack': { // Defensive: normally handled above before the // switch; kept so a late `type: image_stack` without // cellStyle still stacks. const labelField = styleCfg(col, 'label_field', 'labelField') const caption = labelField ? String(getNestedValue(row.original, labelField) ?? '') : undefined return ( ) } default: { if (typeof value === 'object' && value !== null) { return ( ) } const text = value !== null && value !== undefined ? String(value) : '-' // Clamp de texto largo GENÉRICO: un `truncate` en un // span suelto no limita nada dentro de una celda de // tabla (la celda crece con el contenido). Cualquier // texto largo — biografías, notas, direcciones — se // acota a 350px con tooltip del valor completo. La // heurística por longitud cubre lo que la heurística // por nombre ('description') dejaba pasar. const isLongText = text.length > 60 || col.key === 'description' || col.key === 'features' || col.key.includes('description') if (isLongText) { return (
{text}
) } return {text} } } }, enableSorting: col.sortable, enableHiding: true, }) }) // Resolve which actions to surface in the row dropdown: // 1. If the host metadata declares its own actions, use them as-is. // 2. Otherwise, when enableCRUDActions is true, fall back to the // canonical View / Edit / Delete trio so any model with CRUD on // gets the same dropdown without the host having to declare it. // The DynamicTable wires `view`/`edit`/`delete` to its own dialogs // through onAction, so labels/icons are the only thing this needs to // ship. const explicitActions = metadata.actions ?? [] const hasExplicitActions = (metadata.hasActions ?? explicitActions.length > 0) && explicitActions.length > 0 const tx = (key: string, fallback: string) => t ? t(key, { defaultValue: fallback }) : fallback const defaultCRUDActions: typeof explicitActions = metadata.enableCRUDActions ? [ { key: 'view', name: 'view', label: tx('datatable.view', 'Ver'), icon: 'Eye', } as any, { key: 'edit', name: 'edit', label: tx('datatable.edit', 'Editar'), icon: 'Pencil', } as any, { key: 'delete', name: 'delete', label: tx('datatable.delete', 'Eliminar'), icon: 'Trash2', } as any, ] : [] const resolvedActions = hasExplicitActions ? explicitActions : defaultCRUDActions if (resolvedActions.length > 0) { columns.push({ id: 'actions', header: () =>
{t ? t('common.actions') : 'Acciones'}
, size: 80, maxSize: 80, meta: {}, cell: ({ row }) => (
{resolvedActions .filter((action) => isRowActionVisible(action, row.original)) .map((action) => ( onAction && onAction(action.key, row.original)} > {translateMetadataLabel(action.label, t)} ))}
), }) } return columns } } /** * Eager-built variant — equivalent to `makeDefaultGetDynamicColumns()`. Use * this when the host has no helpers to inject and a stable function reference * suffices. */ export const defaultGetDynamicColumns: GetDynamicColumns = makeDefaultGetDynamicColumns()