From ef3942531a3bf79cff801c8b1153dc2863d06cda Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 09:38:19 +0000 Subject: [PATCH 1/3] feat(core): previews for data-defined integrations (Slack, HubSpot, HTML renderers, dry-run workflows) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGC9SnFuqdjX2xH2qeu8vw --- .../core/integrations/data-defined/README.md | 23 + .../core/integrations/data-defined/index.js | 6 + .../data-defined/renderers/common.js | 67 +++ .../data-defined/renderers/html.js | 129 ++++++ .../data-defined/renderers/hubspot.js | 54 +++ .../data-defined/renderers/preview.js | 154 +++++++ .../data-defined/renderers/slack.js | 146 +++++++ .../data-defined/tests/renderers.test.js | 403 ++++++++++++++++++ 8 files changed, 982 insertions(+) create mode 100644 packages/core/integrations/data-defined/renderers/common.js create mode 100644 packages/core/integrations/data-defined/renderers/html.js create mode 100644 packages/core/integrations/data-defined/renderers/hubspot.js create mode 100644 packages/core/integrations/data-defined/renderers/preview.js create mode 100644 packages/core/integrations/data-defined/renderers/slack.js create mode 100644 packages/core/integrations/data-defined/tests/renderers.test.js diff --git a/packages/core/integrations/data-defined/README.md b/packages/core/integrations/data-defined/README.md index 37176a088..39a3f04ef 100644 --- a/packages/core/integrations/data-defined/README.md +++ b/packages/core/integrations/data-defined/README.md @@ -114,6 +114,29 @@ different document at the same version is refused. So is a version that is not n semver bump smaller than the changes. Publishing goes through a conditional write, so two concurrent publishes of one version cannot both win. With `memoryDefinitions()` (definitions bundled in the app) the admin routes answer 501. +## Previews: in the app + +An integration shows up inside the apps it connects: a HubSpot card, a Slack message, digest or approval request. A +preview renders exactly that from the current settings and sample or real records, with the same query, view and step +code production runs (`renderers/`): + +| Function | Does | +|---|---| +| `previewSurface({ def, installation, surfaceId, record, ports, host? })` | `{ surface, view, html, slack?, hubspot? }`. `slack` is Block Kit (`{ blocks, text, truncated }`), `hubspot` a data description of `@hubspot/ui-extensions` components, `html` the same card framed as its host looks. `host` defaults to the surface's own | +| `previewWorkflow({ def, installation, workflowId, event?, record?, manifests, fixtures? })` | Dry run: `{ status, sends, calls, trace, waiting? }`. Each call is `{ stepId, module, capability, kind, args, output, rendered }`; `rendered` is `{ html, slack? }` for writes (a Slack post renders as the message, anything else as a "would call" card) | +| `recordingPorts({ def, manifests, fixtures })` | Ports that record and answer instead of calling an API; give them to `previewSurface` for sample data | +| `viewToSlack`, `viewToHubSpot`, `viewToHtml`, `slackMessageToHtml` | The renderers on their own. `decodeAction(value)` reads a Slack button click back to `{ integrationId?, workflow, surface }` | + +Dry-run rules: nothing is performed (the base ports' API function throws, and the recorder sits in front of it). +`fixtures` answers calls, keyed `"."` (a value, or `(args) => value`; `code:` for code +steps, which are never run), otherwise the manifest's own record shape with ids filled. A webhook workflow with no +`event` runs on the manifest's sample payload. A pausing step ends the preview with `waiting`. + +Everything that is record data is escaped for its target (mrkdwn `& < >` plus fenced `* _ ~`, HTML, text children for +HubSpot), links are http(s) only, Block Kit limits are met by truncating with a marker (50 blocks, 3000/2000/150 +characters, 25 buttons), and nothing throws on an odd tree. Text a *definition* writes into a Slack message (templates) +is sent as authored: a preview shows it as sent, including any raw record data a template interpolates. + ## Syncs `sync-engine.js` runs `syncs`: backfill (a button or `onInstall`, one queued page per message, progress at diff --git a/packages/core/integrations/data-defined/index.js b/packages/core/integrations/data-defined/index.js index 930f29cdf..424549c76 100644 --- a/packages/core/integrations/data-defined/index.js +++ b/packages/core/integrations/data-defined/index.js @@ -15,6 +15,11 @@ const { TRANSFORMS, applyMapping } = require('./transforms'); const { FILTERS } = require('./expressions'); const { validateManifest } = require('./manifests'); const { checkDefinition, checkManifest, checkInstallation } = require('./schema-check'); +const { viewToSlack, escapeMrkdwn } = require('./renderers/slack'); +const { viewToHubSpot } = require('./renderers/hubspot'); +const { viewToHtml, slackMessageToHtml } = require('./renderers/html'); +const { previewSurface, previewWorkflow, recordingPorts } = require('./renderers/preview'); +const { decodeAction } = require('./renderers/common'); const useCases = require('./use-cases/definition-use-cases'); /** Load every capabilities/*.capabilities.json (bundled mocks; real modules ship their own). */ @@ -35,5 +40,6 @@ module.exports = { defineIntegrationClass, defineSharedIntegrationClass, memoryDefinitions, BUILTINS, TRANSFORMS, FILTERS, applyMapping, resolveOptions, loadManifests, loadExamples, + viewToSlack, escapeMrkdwn, viewToHubSpot, viewToHtml, slackMessageToHtml, previewSurface, previewWorkflow, recordingPorts, decodeAction, ...useCases, }; diff --git a/packages/core/integrations/data-defined/renderers/common.js b/packages/core/integrations/data-defined/renderers/common.js new file mode 100644 index 000000000..a8703107d --- /dev/null +++ b/packages/core/integrations/data-defined/renderers/common.js @@ -0,0 +1,67 @@ +/** + * What every renderer shares: coercion of view props (they come from record data, so any type can arrive), the one + * URL rule, truncation that never leaves half an entity behind, and the button <-> Slack round trip. + */ + +/** A view prop as text. Objects are JSON, null/undefined are empty: a renderer never prints "[object Object]" or "undefined". */ +function str(v) { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'object') { try { return JSON.stringify(v); } catch (_) { return ''; } } + return String(v); +} + +/** + * Only http(s) links leave a preview or a card. Record data decides these URLs ("{{data.proposal.recording}}"), and a + * `javascript:` or `data:` URL in a vendor field must not become a clickable thing in someone's HubSpot or Slack. + * @returns {string|null} + */ +function safeUrl(u) { + const s = str(u).trim(); + if (!/^https?:\/\/[^\s<>"']+$/i.test(s)) return null; + try { new URL(s); } catch (_) { return null; } + return s; +} + +/** + * Cut to at most `max` characters, ending in the marker. Never leaves a dangling half of a Slack entity ("&am"), which + * would render as literal junk instead of the character it stood for. + */ +function truncate(s, max, marker = '…') { + s = String(s); + if (s.length <= max) return s; + let cut = s.slice(0, Math.max(0, max - marker.length)); + cut = cut.replace(/&(?:[a-z]{1,4}|#\d{0,6}|#x[0-9a-f]{0,5})?$/i, ''); + return cut + marker; +} + +/** Badge tone (`neutral`, or a Jira statusCategory key like `done`) -> one of four. Unknown is neutral, never an error. */ +function toneOf(tone) { + const t = str(tone).toLowerCase(); + if (['positive', 'success', 'good', 'done', 'green', 'complete', 'completed'].includes(t)) return 'positive'; + if (['warning', 'caution', 'indeterminate', 'yellow', 'in_progress', 'pending'].includes(t)) return 'warning'; + if (['critical', 'danger', 'error', 'bad', 'red', 'failed'].includes(t)) return 'critical'; + return 'neutral'; +} + +const ACTION_VALUE_MAX = 2000; // Slack's cap on a button's value + +/** + * A userAction button's identity as a Slack button value: which installation, which workflow, which surface it was on. + * `needsInput` marks a button whose workflow asks for values first (a Slack button cannot, so the handler opens a form). + */ +function encodeAction({ integrationId, workflow, surface, needsInput }) { + const value = JSON.stringify({ ...(integrationId ? { integrationId: String(integrationId) } : {}), workflow, surface, ...(needsInput ? { needsInput: true } : {}) }); + return value.length <= ACTION_VALUE_MAX ? value : null; +} + +/** The inverse, for the handler that receives a click. null for anything that is not one of ours. */ +function decodeAction(value) { + let v; + try { v = JSON.parse(value); } catch (_) { return null; } + if (!v || typeof v !== 'object' || typeof v.workflow !== 'string' || !v.workflow || typeof v.surface !== 'string') return null; + if (v.integrationId !== undefined && typeof v.integrationId !== 'string') return null; + return { ...(v.integrationId ? { integrationId: v.integrationId } : {}), workflow: v.workflow, surface: v.surface, ...(v.needsInput ? { needsInput: true } : {}) }; +} + +module.exports = { str, safeUrl, truncate, toneOf, encodeAction, decodeAction, ACTION_VALUE_MAX }; diff --git a/packages/core/integrations/data-defined/renderers/html.js b/packages/core/integrations/data-defined/renderers/html.js new file mode 100644 index 000000000..e7e64731a --- /dev/null +++ b/packages/core/integrations/data-defined/renderers/html.js @@ -0,0 +1,129 @@ +/** + * Safe, self-contained HTML for a preview pane. Every piece of data goes through `esc`, links are http(s) only, styles + * are inline attributes and there is no script, no `