Visual Components
Un Visual Component es un componente React que escribís en JSX en Configuración, Nxar compila, y queda disponible como un bloque más del Page Builder (y en otros lugares según su scope). Corre en un iframe aislado con React, la librería Nxar.* y un SDK (sdk) para hablar con la plataforma.
Si lo que necesitás es lógica en el servidor (APIs externas, cálculos, escrituras con reglas), eso es un Logic Component. El patrón habitual es Visual → Logic.
Dónde se monta (scopes)
| Scope | Qué es | Cómo se configura |
|---|---|---|
| Bloque de Record Page | Un panel en la página de un registro: recibe record | Page Builder → Componente custom |
| Bloque de App Page | Un widget en un dashboard o página libre: sin record | Page Builder → Componente custom |
Botón del encabezado (record_header_action) | Un botón en el header del registro que abre un diálogo con el componente, o corre directo | Scope del componente → Page Builder → encabezado → Custom buttons. Ver Record Pages |
| Pantalla de un Stepper | Un bloque Componente propio dentro de una pantalla, que puede escribir variables declaradas | Steppers |
| Página pública | Publicable en /public/<slug>/<token> sin login, con un resolver de datos y acciones | Marcado por el autor como publicable; ver Páginas públicas |
Antes de escribir uno, mirá si un bloque estándar (lista relacionada, gráfico, report embebido, detalles) ya lo resuelve.
Crear uno
- Configuración → Constructor de app → Visual Components → Nuevo: label, name (
snake_case), descripción. - El editor (Monaco, con autocompletado de
sdk,Nxary los tipos) abre con un template. - Al guardar, Nxar compila el JSX; un error de sintaxis aparece como banner rojo y el editor no se cierra.
- Scope (botón en el detalle): dónde puede montarse; para botones del encabezado, además presentación (diálogo con tamaño
sm | md | lg | xl | fullscreeny título, o acción sin pantalla), entidades a las que aplica, permisos requeridos y condiciones de visibilidad sobre el registro.
Contrato
export default function MiComponente(props: {
record?: Record<string, any>; // el registro, solo en Record Pages / botones
config?: Record<string, any>; // JSON que el admin pasa desde el Page Builder
entitySlug?: string; // entidad del registro (sin prefijo de paquete)
ctx: {
user: { id; email; name };
locale: "es" | "en" | "pt";
mode: "admin" | "client";
tenantSlug: string | null;
defaultCurrency?: string; // moneda corporativa
currencyMode?: "single" | "multi";
currencies?: Array<{ code; rate }>;
};
dialogView?: string; // cuando el componente está montado en un diálogo propio
dialogProps?: Record<string, any>;
}) { … }Globales del iframe: React (18, con hooks), sdk (SDK), Nxar (Nxar UI). No hay import: no hay npm ni CSS externo; lo que necesites va en el mismo archivo.
Estilos
Usá Nxar UI + clases de Tailwind: el iframe ya trae window.Nxar y la hoja de estilos del tema, así que tu componente se ve igual que el resto de la aplicación (incluido el modo oscuro).
export default function AccountStats({ record }) {
return (
<Nxar.PageCard>
<Nxar.PageCardHeader title="Resumen" icon={<Nxar.Icon name="building-2" size="xs" />} />
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Facturación</span>
<span className="text-2xl font-semibold">{Nxar.formatCurrency(record?.revenue ?? 0, record?.currency_code)}</span>
</div>
<Nxar.Button variant="brand" size="sm" onClick={() => sdk.openRecord("opportunities", record.last_opp_id)}>
Ver última oportunidad
</Nxar.Button>
</div>
</Nxar.PageCard>
);
}Inline styles y un <style> dentro del JSX también funcionan. Las clases Tailwind disponibles son las que usa la librería; una clase poco común puede no existir en el bundle (detalle).
Acciones con onClick, nunca con <form onSubmit> ni type="submit". El sandbox no dispara el submit nativo: el botón queda muerto, sin request ni error en consola.
Datos: el patrón Visual → Logic
El iframe no puede hacer fetch. Para leer registros usás sdk.fetchRecords / sdk.getRecord; para escribir, sdk.createRecord / updateRecord / deleteRecord (con los permisos del usuario); para cualquier cosa con lógica, APIs externas o credenciales, un Logic Component invocado con sdk.runLogic: las credenciales nunca bajan al navegador y todo queda auditado.
export default function AccountOpportunities({ record }) {
const [opps, setOpps] = React.useState(null);
React.useEffect(() => {
if (!record?.id) return;
let cancelled = false;
sdk.fetchRecords("opportunities", {
filters: [{ field: "account", op: "eq", value: record.id }],
limit: 20, sort: "amount:desc",
}).then((d) => { if (!cancelled) setOpps(d); });
return () => { cancelled = true; };
}, [record?.id]);
if (opps === null) return <Nxar.Skeleton className="m-4 h-6 w-1/2" />;
if (!opps.length) return <Nxar.EmptyState title="Sin oportunidades" />;
return (
<ul className="divide-y">
{opps.map((o) => (
<li key={o.id} className="flex justify-between p-3 text-sm">
<a className="cursor-pointer text-primary" onClick={() => sdk.openRecord("opportunities", o.id)}>{o.name}</a>
<span className="text-muted-foreground">{Nxar.formatCurrency(o.amount, o.currency_code)}</span>
</li>
))}
</ul>
);
}El cancelled evita actualizar estado tras desmontar. Y cuidado con un useEffect cuyas dependencias cambian en cada render: dispara llamadas en loop y choca con el rate limit del SDK.
Agregarlo a una página
Page Builder de la Record Page o App Page → arrastrá Componente custom → elegí el componente → opcionalmente props (JSON, llegan como config) y alto. Los componentes declaran su alto y el iframe crece con el contenido.
Un componente reutilizable se configura por página desde config:
export default function Counter({ config }) {
const { label = "Total", entity, filter } = config ?? {};
const [count, setCount] = React.useState(null);
React.useEffect(() => {
if (!entity) return;
let cancelled = false;
sdk.fetchRecords(entity, { filters: filter ? [filter] : [], limit: 1000 })
.then((d) => { if (!cancelled) setCount(d.length); });
return () => { cancelled = true; };
}, [entity, JSON.stringify(filter)]);
return (
<div className="p-5 text-center">
<div className="text-xs font-semibold uppercase text-muted-foreground">{label}</div>
<div className="mt-1 text-4xl font-bold">{count ?? "—"}</div>
</div>
);
}Botones del encabezado
Un componente con scope record_header_action se monta cuando el usuario hace clic en su botón:
- Presentación diálogo: el shell abre un diálogo del tamaño declarado con tu componente adentro; recibís
recordyentitySlug. Al terminar,await sdk.refreshRecord()para que la página muestre los datos nuevos ysdk.closeDialog(). - Presentación acción: sin pantalla; el componente se monta fuera de la vista, hace su trabajo (normalmente un
sdk.runLogic) y llamasdk.closeDialog(). Guardá unuseRefpara no disparar dos veces si React remonta.
El botón se ve solo si el usuario tiene los permisos requeridos y el registro cumple las condiciones de visibilidad del scope. Los paquetes (CPQ, Fundraising) traen los suyos configurados.
Diálogos propios
Un modal dibujado adentro del iframe queda encerrado en su rectángulo. Pedíselo a la plataforma: sdk.openDialog({ view, props, title, size }) abre un diálogo del shell con tu mismo componente adentro (dialogView / dialogProps), y sdk.closeDialog(resultado) resuelve la promesa. Detalle y reglas en el SDK.
Preview en vivo
El editor muestra código a la izquierda y el preview a la derecha, en el mismo iframe que usan las páginas. Auto-refresh recompila 400 ms después de dejar de tipear (o Refresh manual; un chip STALE avisa si hay cambios sin compilar). El textarea Mock props define el record y el config de prueba; ctx y sdk son los reales (tu usuario).
Debugging
- Error de compilación al guardar: banner con línea y columna (llaves desbalanceadas,
awaitfuera deasync, un tag mal cerrado). - Error en tiempo de ejecución: el iframe lo captura y lo muestra al pie del componente; la consola del navegador tiene el stack.
- “This component has no compiled code yet”: la última compilación falló.
- “This custom component is not a visual component”: pusiste un Logic en un bloque de Visual.
- Un botón que no hace nada: casi siempre un
<form onSubmit>. - Llamadas que fallan con
rate_limit_exceeded: un efecto en loop.
Workflow recomendado
- Definí el contrato: qué
confignecesita, qué datos lee, qué acciones dispara. - Si escribe o llama afuera, escribí primero el Logic de backend y probalo en su Debugger.
- Iterá con el preview y mock props.
- Montalo en una página real y probalo con distintos usuarios (permisos y sharing cambian lo que
sdkdevuelve). - Un componente = un archivo = un
export default; funciones auxiliares internas.
Encontrar el tuyo entre los del paquete
Un tenant con CPQ y Fundraising instalados tiene decenas de componentes que no creó nadie de tu equipo. En Configuración → Visual Components (y en Logic Components) el desplegable Origen, al lado del buscador, acota la lista a:
- Todos los orígenes — sin filtrar.
- Creados acá — solo los de tu workspace, los únicos editables.
- De paquetes — todo lo instalado.
- Paquete: cpq / Paquete: fundraising / … — uno puntual. Aparecen solos, según qué paquetes aportaron componentes a esa lista.
La columna Paquete de la tabla dice de cuál viene cada fila. El mismo filtro está en Automations, Steppers, Global Structures, Entidades, App Pages y Plantillas de email.
Permisos
Crear y editar componentes requiere acceso a Configuración (access_settings). Cada llamada del SDK corre con los permisos del usuario que está mirando la página: si no puede leer una entidad, la promesa rechaza y tu componente tiene que manejarlo. Los componentes instalados por un paquete son de solo lectura; cloná para modificar.