Skip to Content

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)

ScopeQué esCómo se configura
Bloque de Record PageUn panel en la página de un registro: recibe recordPage Builder → Componente custom
Bloque de App PageUn widget en un dashboard o página libre: sin recordPage 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 directoScope del componente → Page Builder → encabezado → Custom buttons. Ver Record Pages
Pantalla de un StepperUn bloque Componente propio dentro de una pantalla, que puede escribir variables declaradasSteppers
Página públicaPublicable en /public/<slug>/<token> sin login, con un resolver de datos y accionesMarcado 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

  1. Configuración → Constructor de app → Visual Components → Nuevo: label, name (snake_case), descripción.
  2. El editor (Monaco, con autocompletado de sdk, Nxar y los tipos) abre con un template.
  3. Al guardar, Nxar compila el JSX; un error de sintaxis aparece como banner rojo y el editor no se cierra.
  4. 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 | fullscreen y 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 record y entitySlug. Al terminar, await sdk.refreshRecord() para que la página muestre los datos nuevos y sdk.closeDialog().
  • Presentación acción: sin pantalla; el componente se monta fuera de la vista, hace su trabajo (normalmente un sdk.runLogic) y llama sdk.closeDialog(). Guardá un useRef para 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, await fuera de async, 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

  1. Definí el contrato: qué config necesita, qué datos lee, qué acciones dispara.
  2. Si escribe o llama afuera, escribí primero el Logic de backend y probalo en su Debugger.
  3. Iterá con el preview y mock props.
  4. Montalo en una página real y probalo con distintos usuarios (permisos y sharing cambian lo que sdk devuelve).
  5. 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.

Last updated on