// 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 (
)
}
/**
* 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 (
)
}
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 (
{
;(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 (
)
}
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: () =>
),
})
}
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()