chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkflowInputField } from '@/lib/workflows/input-format'
|
||||
import {
|
||||
buildCustomBlockConfig,
|
||||
CUSTOM_BLOCK_TILE_COLOR,
|
||||
type CustomBlockRow,
|
||||
isCustomBlockType,
|
||||
} from '@/blocks/custom/build-config'
|
||||
import type { BlockIcon } from '@/blocks/types'
|
||||
|
||||
const icon: BlockIcon = () => null as never
|
||||
|
||||
const row: CustomBlockRow = {
|
||||
type: 'custom_block_abc123',
|
||||
name: 'Invoice Parser',
|
||||
description: 'Extracts fields from an invoice',
|
||||
workflowId: 'wf-1',
|
||||
}
|
||||
|
||||
function findSub(config: ReturnType<typeof buildCustomBlockConfig>, id: string) {
|
||||
return config.subBlocks.find((s) => s.id === id)
|
||||
}
|
||||
|
||||
describe('isCustomBlockType', () => {
|
||||
it('matches only the custom_block_ prefix', () => {
|
||||
expect(isCustomBlockType('custom_block_abc')).toBe(true)
|
||||
expect(isCustomBlockType('agent')).toBe(false)
|
||||
expect(isCustomBlockType(undefined)).toBe(false)
|
||||
expect(isCustomBlockType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCustomBlockConfig', () => {
|
||||
const fields: WorkflowInputField[] = [
|
||||
{ name: 'title', type: 'string' },
|
||||
{ name: 'count', type: 'number' },
|
||||
{ name: 'flag', type: 'boolean' },
|
||||
{ name: 'payload', type: 'object' },
|
||||
{ name: 'items', type: 'array' },
|
||||
{ name: 'docs', type: 'file[]' },
|
||||
]
|
||||
|
||||
it('carries the row identity and always wires the workflow_executor tool', () => {
|
||||
const config = buildCustomBlockConfig(row, fields, { icon })
|
||||
expect(config.type).toBe('custom_block_abc123')
|
||||
expect(config.name).toBe('Invoice Parser')
|
||||
expect(config.category).toBe('tools')
|
||||
expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR)
|
||||
expect(config.hideFromToolbar).toBeUndefined()
|
||||
expect(config.tools.access).toEqual(['workflow_executor'])
|
||||
expect(config.tools.config?.tool({})).toBe('workflow_executor')
|
||||
})
|
||||
|
||||
it('hides a disabled block from the toolbar while keeping it resolvable', () => {
|
||||
expect(buildCustomBlockConfig(row, fields, { icon }).hideFromToolbar).toBeUndefined()
|
||||
expect(
|
||||
buildCustomBlockConfig(row, fields, { icon, hideFromToolbar: true }).hideFromToolbar
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('bakes the bound workflowId as a hidden sub-block', () => {
|
||||
const config = buildCustomBlockConfig(row, fields, { icon })
|
||||
const wf = findSub(config, 'workflowId')
|
||||
expect(wf?.hidden).toBe(true)
|
||||
expect(wf?.value?.({})).toBe('wf-1')
|
||||
})
|
||||
|
||||
it('maps each input field type to the right sub-block', () => {
|
||||
const config = buildCustomBlockConfig(row, fields, { icon })
|
||||
expect(findSub(config, 'title')?.type).toBe('short-input')
|
||||
expect(findSub(config, 'count')?.type).toBe('short-input')
|
||||
expect(findSub(config, 'flag')?.type).toBe('switch')
|
||||
expect(findSub(config, 'payload')?.type).toBe('code')
|
||||
expect(findSub(config, 'payload')?.language).toBe('json')
|
||||
expect(findSub(config, 'items')?.type).toBe('code')
|
||||
expect(findSub(config, 'docs')?.type).toBe('file-upload')
|
||||
expect(findSub(config, 'docs')?.multiple).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes the full result and hides plumbing when no outputs are curated', () => {
|
||||
const config = buildCustomBlockConfig(row, fields, { icon })
|
||||
expect(Object.keys(config.outputs).sort()).toEqual(['error', 'result', 'success'])
|
||||
expect(config.outputs.childWorkflowId).toBeUndefined()
|
||||
expect(config.outputs.childTraceSpans).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes only curated outputs as named fields', () => {
|
||||
const config = buildCustomBlockConfig(
|
||||
{ ...row, exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'email' }] },
|
||||
fields,
|
||||
{ icon }
|
||||
)
|
||||
expect(config.outputs.email).toEqual({ type: 'json', description: 'Output: content' })
|
||||
expect(config.outputs.result).toBeUndefined()
|
||||
expect(config.outputs.success).toBeDefined()
|
||||
expect(config.outputs.childWorkflowId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('anchors the sub-block on the stable field id, showing the name as title', () => {
|
||||
const config = buildCustomBlockConfig(row, [{ id: 'fld-1', name: 'title', type: 'string' }], {
|
||||
icon,
|
||||
})
|
||||
const sub = findSub(config, 'fld-1')
|
||||
expect(sub).toBeDefined()
|
||||
expect(sub?.title).toBe('title')
|
||||
expect(findSub(config, 'title')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the field name as id when a field has no stable id', () => {
|
||||
const config = buildCustomBlockConfig(row, [{ name: 'legacy', type: 'string' }], { icon })
|
||||
expect(findSub(config, 'legacy')?.title).toBe('legacy')
|
||||
})
|
||||
|
||||
it('assembles inputMapping from non-reserved, non-empty params', () => {
|
||||
const config = buildCustomBlockConfig(row, fields, { icon })
|
||||
const mappingFn = findSub(config, 'inputMapping')?.value
|
||||
const json = mappingFn?.({
|
||||
workflowId: 'wf-1',
|
||||
inputMapping: 'ignored',
|
||||
triggerMode: true,
|
||||
title: 'Acme',
|
||||
count: 3,
|
||||
empty: '',
|
||||
})
|
||||
expect(JSON.parse(json as string)).toEqual({ title: 'Acme', count: 3 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { SubBlockType } from '@sim/workflow-types/blocks'
|
||||
import type { WorkflowInputField } from '@/lib/workflows/input-format'
|
||||
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* The block-type prefix that identifies a custom (deploy-as-block) block. Shared
|
||||
* by the registry overlay, the executor handler dispatch, and access control.
|
||||
*/
|
||||
export const CUSTOM_BLOCK_TYPE_PREFIX = 'custom_block_'
|
||||
|
||||
/** Whether a block type is a published custom block. */
|
||||
export function isCustomBlockType(type: string | undefined | null): type is string {
|
||||
return typeof type === 'string' && type.startsWith(CUSTOM_BLOCK_TYPE_PREFIX)
|
||||
}
|
||||
|
||||
/** Tile background for custom-block icons (the uploaded image renders on top). */
|
||||
export const CUSTOM_BLOCK_TILE_COLOR = '#6F6F6F'
|
||||
|
||||
/** A curated output exposed on the block, mapped from a child block output. */
|
||||
export interface CustomBlockOutput {
|
||||
blockId: string
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A curated input the admin chose to expose on the block, keyed by the source
|
||||
* Start field's stable `id`, with optional consumer-facing hints.
|
||||
*/
|
||||
export interface CustomBlockInput {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
placeholder?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The DB-backed identity + presentation of a custom block. `workflowId` is the
|
||||
* bound source workflow whose LATEST deployment this block always executes.
|
||||
*/
|
||||
export interface CustomBlockRow {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
workflowId: string
|
||||
/** Curated exposed outputs; empty/absent exposes the child's whole `result`. */
|
||||
exposedOutputs?: CustomBlockOutput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Params that carry the block's own wiring rather than a mapped Start input.
|
||||
* Everything else on the block is collected into the child `inputMapping`. Shared
|
||||
* with the serializer so "does this custom block declare input sub-blocks?" reads
|
||||
* from one source instead of re-listing the structural ids.
|
||||
*/
|
||||
export const RESERVED_PARAMS = new Set([
|
||||
'workflowId',
|
||||
'inputMapping',
|
||||
'triggerMode',
|
||||
'advancedMode',
|
||||
])
|
||||
|
||||
/** Map a Start input field type to the editor sub-block type used to collect it. */
|
||||
function subBlockTypeForField(fieldType: string): SubBlockType {
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return 'switch'
|
||||
case 'object':
|
||||
case 'array':
|
||||
return 'code'
|
||||
case 'file[]':
|
||||
return 'file-upload'
|
||||
default:
|
||||
return 'short-input'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a `BlockConfig` for a published custom block from its DB row and the
|
||||
* live-derived Start input fields. Shared by the client (real icon + per-field
|
||||
* editors) and the server (placeholder icon + `inputFields: []`, since the
|
||||
* `inputMapping` wiring is schema-agnostic).
|
||||
*
|
||||
* Execution reuses the `workflow_executor` tool: the bound `workflowId` and the
|
||||
* assembled `inputMapping` are hidden, baked sub-blocks; each Start input becomes
|
||||
* its own editable sub-block whose value is collected into `inputMapping`.
|
||||
* `<refs>` inside those values resolve at execution exactly like the
|
||||
* `workflow_input` block.
|
||||
*
|
||||
* The sub-block id is the field's stable id (`field.id`), NOT its display name, so
|
||||
* renaming a Start input in the source workflow and redeploying never orphans a
|
||||
* consumer's placed value. The name is shown as the sub-block title and is what
|
||||
* the child workflow ultimately receives — the id→name remap happens at execution
|
||||
* in `WorkflowBlockHandler` against the loaded child's current field names. Legacy
|
||||
* fields without an id fall back to keying on the name.
|
||||
*/
|
||||
export function buildCustomBlockConfig(
|
||||
row: CustomBlockRow,
|
||||
inputFields: WorkflowInputField[],
|
||||
opts: { icon: BlockIcon; bgColor?: string; hideFromToolbar?: boolean }
|
||||
): BlockConfig {
|
||||
const fieldSubBlocks: SubBlockConfig[] = inputFields.map((field) => {
|
||||
const type = subBlockTypeForField(field.type)
|
||||
const sub: SubBlockConfig = {
|
||||
id: field.id ?? field.name,
|
||||
title: field.name,
|
||||
type,
|
||||
description: field.description,
|
||||
placeholder: field.placeholder,
|
||||
// Serializer Loop-B (required subBlocks not covered by tool params) and the
|
||||
// editor asterisk both read this — same enforcement path as regular blocks.
|
||||
required: field.required === true,
|
||||
}
|
||||
if (field.type === 'object' || field.type === 'array') sub.language = 'json'
|
||||
if (field.type === 'file[]') sub.multiple = true
|
||||
return sub
|
||||
})
|
||||
|
||||
return {
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
category: 'tools',
|
||||
longDescription:
|
||||
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +
|
||||
'fields; it runs the underlying workflow and returns the outputs below. The bound ' +
|
||||
'workflow is baked in — no workflow id or input mapping to configure.',
|
||||
bgColor: opts.bgColor ?? CUSTOM_BLOCK_TILE_COLOR,
|
||||
icon: opts.icon,
|
||||
// A disabled block stays resolvable (so a still-placed instance survives
|
||||
// serialization and fails loudly at run via `getCustomBlockAuthority`, instead
|
||||
// of silently vanishing from the graph) but is hidden from the palette so no
|
||||
// new instance can be placed.
|
||||
hideFromToolbar: opts.hideFromToolbar,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'workflowId',
|
||||
type: 'short-input',
|
||||
hidden: true,
|
||||
value: () => row.workflowId,
|
||||
},
|
||||
{
|
||||
id: 'inputMapping',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
hidden: true,
|
||||
value: (params) => {
|
||||
const mapping: Record<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (RESERVED_PARAMS.has(key)) continue
|
||||
if (val === undefined || val === '') continue
|
||||
mapping[key] = val
|
||||
}
|
||||
return JSON.stringify(mapping)
|
||||
},
|
||||
},
|
||||
...fieldSubBlocks,
|
||||
],
|
||||
tools: {
|
||||
access: ['workflow_executor'],
|
||||
config: {
|
||||
tool: () => 'workflow_executor',
|
||||
params: (params) => ({
|
||||
workflowId: params.workflowId,
|
||||
inputMapping: params.inputMapping,
|
||||
}),
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
workflowId: { type: 'string', description: 'Bound source workflow id' },
|
||||
inputMapping: { type: 'json', description: 'Mapping of input fields to values' },
|
||||
},
|
||||
outputs: buildOutputs(row.exposedOutputs),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The block's declared outputs. Internal plumbing (child workflow id/name, trace
|
||||
* spans) is never exposed. With curated `exposedOutputs`, each becomes its own
|
||||
* named output; otherwise the whole child `result` is exposed.
|
||||
*/
|
||||
function buildOutputs(exposed: CustomBlockOutput[] | undefined): BlockConfig['outputs'] {
|
||||
const outputs: BlockConfig['outputs'] = {
|
||||
success: { type: 'boolean', description: 'Execution success status' },
|
||||
error: { type: 'string', description: 'Error message' },
|
||||
}
|
||||
if (exposed && exposed.length > 0) {
|
||||
for (const out of exposed) {
|
||||
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
|
||||
}
|
||||
} else {
|
||||
outputs.result = { type: 'json', description: 'Workflow execution result' }
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Client-side custom-block overlay: a mutable Map, hydrated from
|
||||
* `useCustomBlocks` by `CustomBlocksProvider`, that the `@/blocks/registry`
|
||||
* accessors fall back to. Scoped to the active workspace's org; re-hydrated on
|
||||
* workspace switch.
|
||||
*
|
||||
* Because many consumers snapshot `getAllBlocks()` (the cmd+K search, the Access
|
||||
* Control block list), the overlay is also an external store: `version` bumps on
|
||||
* every hydrate and listeners are notified, so those consumers can re-read via
|
||||
* {@link useCustomBlockOverlayVersion} instead of going stale until a refresh.
|
||||
*/
|
||||
let map = new Map<string, BlockConfig>()
|
||||
let version = 0
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
registerBlockOverlayResolver({
|
||||
get: (type) => map.get(type),
|
||||
all: () => [...map.values()],
|
||||
})
|
||||
|
||||
/**
|
||||
* Bump the overlay version and notify subscribers that block-registry-derived
|
||||
* data changed. Shared signal for BOTH custom-block hydration and the
|
||||
* block-visibility hydrate path — subscribers treat the version as an opaque
|
||||
* "re-read `getAllBlocks()`" token, never as "custom blocks specifically
|
||||
* changed".
|
||||
*/
|
||||
export function notifyBlockOverlayChanged(): void {
|
||||
version += 1
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/** Replace the in-scope custom blocks and notify subscribers. */
|
||||
export function hydrateClientCustomBlocks(configs: BlockConfig[]): void {
|
||||
map = new Map(configs.map((config) => [config.type, config]))
|
||||
notifyBlockOverlayChanged()
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a component to overlay changes. Returns a monotonic version that
|
||||
* changes on every hydrate — include it in a `useMemo`/`useEffect` dep list to
|
||||
* recompute anything derived from `getAllBlocks()` when custom blocks load.
|
||||
*/
|
||||
export function useCustomBlockOverlayVersion(): number {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => version,
|
||||
() => 0
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { memo, type SVGProps } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { Box } from 'lucide-react'
|
||||
import type { BlockIcon } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Build a `BlockIcon` from an uploaded icon image URL. Rendered as an `<img>` so
|
||||
* any uploaded PNG/JPEG/SVG works; `className` (size) is forwarded like every
|
||||
* other block icon. Cached by URL so the component reference stays stable across
|
||||
* the many tiles/nodes that render a custom block.
|
||||
*/
|
||||
const cache = new Map<string, BlockIcon>()
|
||||
|
||||
export function makeImageIcon(url: string): BlockIcon {
|
||||
const cached = cache.get(url)
|
||||
if (cached) return cached
|
||||
|
||||
// Fill the tile so an uploaded image/logo reads at the same footprint as other
|
||||
// blocks' colored tiles, instead of a small glyph floating in a transparent
|
||||
// square. Trailing `size-full` beats a consumer size *class* (twMerge keeps the
|
||||
// last of a conflict group) so a tiled surface (canvas/toolbar/palette) fills;
|
||||
// it loses to a consumer inline `style` (specificity) so a tile-less inline
|
||||
// surface that sizes via `style={{ width, height }}` still renders at its px.
|
||||
const ImageComponent = memo(({ className, style }: SVGProps<SVGSVGElement>) => (
|
||||
<img
|
||||
src={url}
|
||||
alt=''
|
||||
style={style}
|
||||
className={cn('rounded-[4px] object-contain', className, 'size-full')}
|
||||
/>
|
||||
))
|
||||
// double-cast-allowed: an <img> renderer must satisfy the SVG-typed BlockIcon slot
|
||||
const Icon = ImageComponent as unknown as BlockIcon
|
||||
|
||||
cache.set(url, Icon)
|
||||
return Icon
|
||||
}
|
||||
|
||||
/** Fallback icon for custom blocks published without an uploaded image. */
|
||||
// double-cast-allowed: a lucide icon component fills the SVG-typed BlockIcon slot
|
||||
export const DefaultCustomBlockIcon: BlockIcon = Box as unknown as BlockIcon
|
||||
|
||||
/**
|
||||
* Resolve a custom block's icon: the uploaded image, else the org's whitelabel logo
|
||||
* (`fallbackUrl`), else the default glyph.
|
||||
*/
|
||||
export function getCustomBlockIcon(
|
||||
iconUrl: string | null | undefined,
|
||||
fallbackUrl?: string | null
|
||||
): BlockIcon {
|
||||
const url = iconUrl || fallbackUrl
|
||||
return url ? makeImageIcon(url) : DefaultCustomBlockIcon
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
|
||||
import {
|
||||
overlayBlocks,
|
||||
registerBlockOverlayResolver,
|
||||
resolveOverlayBlock,
|
||||
} from '@/blocks/custom/overlay'
|
||||
import type { BlockIcon } from '@/blocks/types'
|
||||
|
||||
const icon: BlockIcon = () => null as never
|
||||
|
||||
const config = buildCustomBlockConfig(
|
||||
{ type: 'custom_block_xyz', name: 'X', description: '', workflowId: 'wf-9' },
|
||||
[],
|
||||
{ icon }
|
||||
)
|
||||
|
||||
afterEach(() => registerBlockOverlayResolver(null))
|
||||
|
||||
describe('block overlay resolver', () => {
|
||||
it('returns undefined/empty with no resolver registered', () => {
|
||||
expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined()
|
||||
expect(overlayBlocks()).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves through a registered resolver and clears on null', () => {
|
||||
const map = new Map([[config.type, config]])
|
||||
registerBlockOverlayResolver({ get: (t) => map.get(t), all: () => [...map.values()] })
|
||||
|
||||
expect(resolveOverlayBlock('custom_block_xyz')).toBe(config)
|
||||
expect(resolveOverlayBlock('nope')).toBeUndefined()
|
||||
expect(overlayBlocks()).toEqual([config])
|
||||
|
||||
registerBlockOverlayResolver(null)
|
||||
expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined()
|
||||
expect(overlayBlocks()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Resolver for dynamic (DB-driven) custom blocks that live outside the static
|
||||
* `BLOCK_REGISTRY`. The four core accessors in `@/blocks/registry` fall back to
|
||||
* the registered resolver so custom block types resolve everywhere without
|
||||
* rewriting the many synchronous `getBlock` call sites.
|
||||
*
|
||||
* Two environment-specific resolvers register here:
|
||||
* - client: a Map hydrated from `useCustomBlocks` (see `client-overlay.ts`)
|
||||
* - server: an AsyncLocalStorage map scoped per request/org (see `server-overlay.ts`)
|
||||
*
|
||||
* This module is isomorphic (no `'use client'`, no `node:` imports) so
|
||||
* `registry.ts` stays importable on both sides.
|
||||
*/
|
||||
export interface BlockOverlayResolver {
|
||||
get(type: string): BlockConfig | undefined
|
||||
all(): BlockConfig[]
|
||||
}
|
||||
|
||||
let resolver: BlockOverlayResolver | null = null
|
||||
|
||||
/** Register (or clear with `null`) the active overlay resolver for this environment. */
|
||||
export function registerBlockOverlayResolver(next: BlockOverlayResolver | null): void {
|
||||
resolver = next
|
||||
}
|
||||
|
||||
/** Resolve a single custom block config by type, or `undefined` when none applies. */
|
||||
export function resolveOverlayBlock(type: string): BlockConfig | undefined {
|
||||
return resolver?.get(type)
|
||||
}
|
||||
|
||||
/** All custom block configs currently in scope (empty when no resolver is active). */
|
||||
export function overlayBlocks(): BlockConfig[] {
|
||||
return resolver?.all() ?? []
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import type { WorkflowInputField } from '@/lib/workflows/input-format'
|
||||
import { buildCustomBlockConfig, type CustomBlockRow } from '@/blocks/custom/build-config'
|
||||
import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
|
||||
import type { BlockConfig, BlockIcon } from '@/blocks/types'
|
||||
|
||||
/** A row for the overlay, optionally carrying live-derived Start input fields. */
|
||||
type CustomBlockOverlayRow = CustomBlockRow & {
|
||||
inputFields?: WorkflowInputField[]
|
||||
/** When `false`, the block resolves but is hidden from the palette (disabled). */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side custom-block overlay. Resolves `custom_block_*` types during
|
||||
* serialization + execution from a per-request, per-org map held in
|
||||
* AsyncLocalStorage — keeping the `@/blocks/registry` accessors synchronous while
|
||||
* isolating concurrent requests across different organizations.
|
||||
*/
|
||||
|
||||
/** Icon is never rendered server-side (the serializer ignores it). */
|
||||
const PLACEHOLDER_ICON: BlockIcon = () => null as never
|
||||
|
||||
const store = new AsyncLocalStorage<Map<string, BlockConfig>>()
|
||||
|
||||
registerBlockOverlayResolver({
|
||||
get: (type) => store.getStore()?.get(type),
|
||||
all: () => [...(store.getStore()?.values() ?? [])],
|
||||
})
|
||||
|
||||
/**
|
||||
* Run `fn` with the given org's custom blocks resolvable via `getBlock`/
|
||||
* `getAllBlocks`. Wrap every execution serializer entry point (execute route,
|
||||
* trigger.dev task, scheduled/webhook runs) at the org-context boundary so a
|
||||
* workflow containing a custom block can serialize and execute.
|
||||
*
|
||||
* Execution passes bare rows: `inputMapping` is schema-agnostic, so no per-field
|
||||
* editors are needed. Agent-facing callers (`get_blocks_metadata`, `edit_workflow`)
|
||||
* pass rows carrying `inputFields` so `getBlock` exposes the real input sub-blocks —
|
||||
* matching what the VFS block files show — instead of an empty schema.
|
||||
*/
|
||||
export function withCustomBlockOverlay<T>(
|
||||
rows: CustomBlockOverlayRow[],
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const map = new Map<string, BlockConfig>()
|
||||
for (const row of rows) {
|
||||
map.set(
|
||||
row.type,
|
||||
buildCustomBlockConfig(row, row.inputFields ?? [], {
|
||||
icon: PLACEHOLDER_ICON,
|
||||
hideFromToolbar: row.enabled === false,
|
||||
})
|
||||
)
|
||||
}
|
||||
return store.run(map, fn)
|
||||
}
|
||||
Reference in New Issue
Block a user