chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,811 @@
'use client'
import { useMemo, useState } from 'react'
import {
Badge,
Button,
ChipCombobox,
ChipConfirmModal,
ChipInput,
ChipModalField,
ChipTextarea,
type ComboboxOptionGroup,
cn,
Expandable,
ExpandableContent,
Label,
Loader,
Switch,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { ArrowLeft, ChevronDown, Image as ImageIcon, X } from 'lucide-react'
import {
type FlattenOutputsBlockInput,
type FlattenOutputsEdgeInput,
flattenWorkflowOutputs,
} from '@/lib/workflows/blocks/flatten-outputs'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { DropZone } from '@/app/workspace/[workspaceId]/components/drop-zone'
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import type { CustomBlockInput, CustomBlockOutput } from '@/blocks/custom/build-config'
import { SettingRow } from '@/ee/components/setting-row'
import {
useCustomBlocks,
useCustomBlockUsageCounts,
useDeleteCustomBlock,
usePublishCustomBlock,
useUpdateCustomBlock,
} from '@/hooks/queries/custom-blocks'
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
const OUTPUT_SEP = '::'
const ICON_ACCEPT = 'image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
const encodeOutput = (blockId: string, path: string) => `${blockId}${OUTPUT_SEP}${path}`
const decodeOutput = (value: string) => {
const i = value.indexOf(OUTPUT_SEP)
return i === -1
? { blockId: '', path: value }
: { blockId: value.slice(0, i), path: value.slice(i + OUTPUT_SEP.length) }
}
/** Derive a unique, friendly output name from a dot-path, avoiding collisions. */
function deriveOutputName(path: string, taken: Set<string>): string {
const base = (path.split('.').pop() || path).replace(/[^a-zA-Z0-9_]/g, '_')
let name = base
let n = 2
while (taken.has(name)) name = `${base}_${n++}`
taken.add(name)
return name
}
interface CustomBlockDetailProps {
blockId: string | null
workspaceId: string
onBack: () => void
}
export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockDetailProps) {
const isCreate = blockId === null
const { data: blocks = [] } = useCustomBlocks(workspaceId)
const existing = useMemo(
() => (blockId ? (blocks.find((b) => b.id === blockId) ?? null) : null),
[blocks, blockId]
)
const publish = usePublishCustomBlock(workspaceId)
const update = useUpdateCustomBlock(workspaceId)
const remove = useDeleteCustomBlock(workspaceId)
// Needed in both modes: the source picker (create) and the manage gate (edit).
const { data: workspaces = [] } = useWorkspacesQuery()
// A block is manageable only by an admin of its SOURCE workspace — the same authz
// the publish/update/delete routes enforce. Everyone else gets a read-only view
// (no Save/Discard, no Delete, disabled fields). Create is always manageable: the
// list gates the entry on workspace-admin and the picker only offers admin workspaces.
const canManageBlock =
isCreate || workspaces.some((w) => w.id === existing?.workspaceId && w.permissions === 'admin')
// Custom blocks are org-scoped and the settings list only shows the current org's
// blocks, so a block published to another org's workspace would silently never
// appear here. Restrict the picker to workspaces in the current workspace's org.
const currentOrgId = useMemo(
() => workspaces.find((w) => w.id === workspaceId)?.organizationId ?? null,
[workspaces, workspaceId]
)
// Only workspaces the user can publish from (admin) — the publish route requires
// admin on the source workspace, so a member/read workspace can never be a source.
const orgWorkspaces = useMemo(
() =>
currentOrgId
? workspaces.filter((w) => w.organizationId === currentOrgId && w.permissions === 'admin')
: [],
[workspaces, currentOrgId]
)
const eligibleDefaultWorkspaceId = useMemo(
() =>
orgWorkspaces.some((w) => w.id === workspaceId)
? workspaceId
: (orgWorkspaces[0]?.id ?? workspaceId),
[orgWorkspaces, workspaceId]
)
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(workspaceId)
// Once the eligible list loads, snap an ineligible selection (e.g. the current
// workspace when the user isn't its admin) to the first workspace they can publish from.
if (
isCreate &&
orgWorkspaces.length > 0 &&
!orgWorkspaces.some((w) => w.id === selectedWorkspaceId)
) {
setSelectedWorkspaceId(eligibleDefaultWorkspaceId)
}
const [selectedWorkflowId, setSelectedWorkflowId] = useState('')
const { data: workflows = [] } = useWorkflows(isCreate ? selectedWorkspaceId : undefined)
const workflowId = isCreate ? selectedWorkflowId : (existing?.workflowId ?? '')
const [name, setName] = useState(existing?.name ?? '')
const [description, setDescription] = useState(existing?.description ?? '')
const [inputs, setInputs] = useState<CustomBlockInput[]>(() =>
toCustomBlockInputs(existing?.inputFields)
)
const [outputs, setOutputs] = useState<CustomBlockOutput[]>(() => existing?.exposedOutputs ?? [])
const [error, setError] = useState<string | null>(null)
const [showDelete, setShowDelete] = useState(false)
// Type-to-confirm buffer for the delete modal — reset whenever the modal opens.
const [confirmationText, setConfirmationText] = useState('')
const [prevShowDelete, setPrevShowDelete] = useState(false)
if (showDelete !== prevShowDelete) {
setPrevShowDelete(showDelete)
if (showDelete) setConfirmationText('')
}
// Fetched with the view so the count is warm before the delete modal opens.
// Server-gated on the same manage authz as delete.
const usageCountsQuery = useCustomBlockUsageCounts(existing?.id, { enabled: canManageBlock })
const usageCount = usageCountsQuery.data?.usageCount ?? 0
// Edit mode may mount before `useCustomBlocks` has resolved this row, leaving the
// buffers empty. Reseed them the first time the block's identity loads (or when it
// changes) — keyed on `existing.id` so a later refetch of the SAME block doesn't
// clobber in-progress edits. (The icon reseeds itself via `currentImage`.)
const [seededId, setSeededId] = useState(existing?.id ?? null)
if (existing && existing.id !== seededId) {
setSeededId(existing.id)
setName(existing.name)
setDescription(existing.description ?? '')
setInputs(toCustomBlockInputs(existing.inputFields))
setOutputs(existing.exposedOutputs ?? [])
}
const iconUpload = useProfilePictureUpload({
currentImage: existing?.iconUrl ?? null,
onError: (e) => setError(e),
context: 'workspace-logos',
workspaceId,
})
const iconUrl = iconUpload.previewUrl
// The block always runs the source's LATEST DEPLOYMENT, so curate against the
// deployed state (draft-only blocks/inputs would resolve to nothing at runtime).
const deployed = useDeployedWorkflowState(workflowId || null)
const deployedLoaded = Boolean(deployed.data) && !deployed.isLoading
const notDeployed = Boolean(workflowId) && !deployed.isLoading && deployed.data === null
const availableFields = useMemo(() => {
const state = deployed.data as { blocks?: Record<string, unknown> } | null | undefined
if (!state?.blocks) return []
return extractInputFieldsFromBlocks(state.blocks)
}, [deployed.data])
const fieldById = useMemo(() => {
const m = new Map<string, (typeof availableFields)[number]>()
for (const f of availableFields) m.set(f.id ?? f.name, f)
return m
}, [availableFields])
const overrideById = useMemo(() => {
const m = new Map<string, CustomBlockInput>()
for (const i of inputs) m.set(i.id, i)
return m
}, [inputs])
// Every deployed Start input is exposed (no selection). Name/type/description are
// inherited from the field itself (the Start block already defines them); only
// the placeholder and required flag are authored here.
const visibleInputs = useMemo<CustomBlockInput[]>(
() =>
deployedLoaded
? availableFields.map((f) => {
const id = f.id ?? f.name
return {
id,
name: f.name,
type: f.type,
description: f.description,
placeholder: overrideById.get(id)?.placeholder,
required: overrideById.get(id)?.required,
}
})
: inputs,
[deployedLoaded, availableFields, overrideById, inputs]
)
const [expandedInputs, setExpandedInputs] = useState<ReadonlySet<string>>(new Set())
const toggleInput = (id: string) =>
setExpandedInputs((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const { outputGroups, labelByKey } = useMemo(() => {
const state = deployed.data as
| { blocks?: Record<string, FlattenOutputsBlockInput>; edges?: FlattenOutputsEdgeInput[] }
| null
| undefined
const labels = new Map<string, string>()
if (!state?.blocks) return { outputGroups: [] as ComboboxOptionGroup[], labelByKey: labels }
const flat = flattenWorkflowOutputs(Object.values(state.blocks), state.edges ?? [])
const byBlock = new Map<
string,
{ blockName: string; items: { label: string; value: string }[] }
>()
for (const f of flat) {
const key = encodeOutput(f.blockId, f.path)
labels.set(key, `${f.blockName} ${f.path}`)
const group = byBlock.get(f.blockId) ?? { blockName: f.blockName, items: [] }
group.items.push({ label: f.path, value: key })
byBlock.set(f.blockId, group)
}
return {
outputGroups: Array.from(byBlock.values()).map((g) => ({
section: g.blockName,
items: g.items,
})),
labelByKey: labels,
}
}, [deployed.data])
const visibleOutputs = useMemo(
() =>
deployedLoaded
? outputs.filter((o) => labelByKey.has(encodeOutput(o.blockId, o.path)))
: outputs,
[outputs, deployedLoaded, labelByKey]
)
const selectedOutputKeys = useMemo(
() => visibleOutputs.map((o) => encodeOutput(o.blockId, o.path)),
[visibleOutputs]
)
const dirty = existing
? name.trim() !== existing.name ||
description.trim() !== (existing.description ?? '') ||
(iconUrl || null) !== (existing.iconUrl ?? null) ||
JSON.stringify(visibleOutputs) !== JSON.stringify(existing.exposedOutputs) ||
JSON.stringify(normalizeInputsForCompare(visibleInputs)) !==
JSON.stringify(normalizeInputsForCompare(existing.inputFields))
: Boolean(
name.trim() ||
description.trim() ||
selectedWorkflowId ||
selectedWorkspaceId !== eligibleDefaultWorkspaceId ||
iconUrl ||
visibleOutputs.length > 0 ||
visibleInputs.some((i) => i.placeholder?.trim())
)
const guard = useSettingsUnsavedGuard({ isDirty: dirty })
const saving = publish.isPending || update.isPending || remove.isPending
// Outputs are required — there is no "expose the whole result" option.
const saveDisabled =
!name.trim() ||
!workflowId ||
notDeployed ||
iconUpload.isUploading ||
deployed.isLoading ||
(deployedLoaded && visibleOutputs.length === 0)
// Upsert an authored per-input override (placeholder/required). `visibleInputs`
// shows every deployed field; the first edit of a field adds its override row.
function setInputOverride(
id: string,
patch: Partial<Pick<CustomBlockInput, 'placeholder' | 'required'>>
) {
setInputs((prev) => {
if (prev.some((i) => i.id === id)) {
return prev.map((i) => (i.id === id ? { ...i, ...patch } : i))
}
const f = fieldById.get(id)
return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', ...patch }]
})
}
function handleOutputsChange(nextKeys: string[]) {
const byKey = new Map(outputs.map((o) => [encodeOutput(o.blockId, o.path), o]))
const taken = new Set(outputs.map((o) => o.name))
setOutputs(
nextKeys.map((key) => {
const ex = byKey.get(key)
if (ex) return ex
const { blockId, path } = decodeOutput(key)
return { blockId, path, name: deriveOutputName(path, taken) }
})
)
}
function setOutputName(key: string, value: string) {
setOutputs((prev) =>
prev.map((o) => (encodeOutput(o.blockId, o.path) === key ? { ...o, name: value } : o))
)
}
function handleDiscard() {
if (isCreate) {
setSelectedWorkspaceId(eligibleDefaultWorkspaceId)
setSelectedWorkflowId('')
}
setName(existing?.name ?? '')
setDescription(existing?.description ?? '')
setInputs(toCustomBlockInputs(existing?.inputFields))
setOutputs(existing?.exposedOutputs ?? [])
iconUpload.reset()
setError(null)
}
async function handleSave() {
setError(null)
const exposedOutputs = visibleOutputs.map((o) => ({ ...o, name: o.name.trim() }))
if (exposedOutputs.length === 0) {
setError('Pick at least one output to expose')
return
}
if (exposedOutputs.some((o) => !o.name)) {
setError('Every exposed output needs a name')
return
}
if (new Set(exposedOutputs.map((o) => o.name)).size !== exposedOutputs.length) {
setError('Output names must be unique')
return
}
// Only the placeholder and required flag are authored; the field set/name/type
// are always derived from the deployed Start. Persist only non-empty overrides.
const inputPlaceholders = visibleInputs
.filter((i) => i.placeholder?.trim() || i.required)
.map((i) => ({
id: i.id,
...(i.placeholder?.trim() ? { placeholder: i.placeholder.trim() } : {}),
...(i.required ? { required: true } : {}),
}))
try {
if (existing) {
const iconChanged = (iconUrl || null) !== (existing.iconUrl ?? null)
await update.mutateAsync({
id: existing.id,
name: name.trim(),
description: description.trim(),
inputs: inputPlaceholders,
exposedOutputs,
...(iconChanged ? { iconUrl: iconUrl || null } : {}),
})
toast.success('Block updated')
} else {
await publish.mutateAsync({
workspaceId: selectedWorkspaceId,
workflowId,
name: name.trim(),
description: description.trim(),
inputs: inputPlaceholders,
exposedOutputs,
...(iconUrl ? { iconUrl } : {}),
})
toast.success('Block created')
}
onBack()
} catch (e) {
setError(getErrorMessage(e, 'Failed to save block'))
}
}
async function handleDelete() {
if (!existing) return
try {
await remove.mutateAsync(existing.id)
toast.success('Block deleted')
onBack()
} catch (e) {
setError(getErrorMessage(e, 'Failed to delete block'))
}
}
return (
<>
<SettingsPanel
back={{ text: 'Custom blocks', icon: ArrowLeft, onSelect: () => guard.guardBack(onBack) }}
title={existing?.name || 'New block'}
description='Publish a deployed workflow as a reusable, org-wide block.'
actions={[
...(canManageBlock
? saveDiscardActions({
dirty,
saving,
onSave: handleSave,
onDiscard: handleDiscard,
saveDisabled,
})
: []),
...(existing && canManageBlock
? [
{
text: remove.isPending ? 'Deleting...' : 'Delete',
variant: 'destructive' as const,
onSelect: () => {
setShowDelete(true)
// The warning must reflect the org's CURRENT usage, not a
// ≤30s-stale cache entry.
usageCountsQuery.refetch()
},
disabled: remove.isPending,
},
]
: []),
]}
>
<div className='flex flex-col gap-5 py-1'>
{error && (
<div className='rounded-md border border-[color-mix(in_srgb,var(--text-error)_20%,transparent)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] px-3 py-2 text-[var(--text-error)] text-small'>
{error}
</div>
)}
{isCreate && (
<>
<SettingRow label='Workspace'>
<ChipCombobox
className='w-full'
dropdownWidth='trigger'
searchable
placeholder='Select a workspace'
options={orgWorkspaces.map((w) => ({ value: w.id, label: w.name }))}
value={selectedWorkspaceId}
onChange={(v: string) => {
setSelectedWorkspaceId(v)
setSelectedWorkflowId('')
}}
/>
</SettingRow>
<SettingRow label='Workflow' description='Only deployed workflows can be published.'>
<ChipCombobox
className='w-full'
dropdownWidth='trigger'
searchable
placeholder='Select a workflow'
emptyMessage='No workflows in this workspace.'
options={workflows.map((w) => ({ value: w.id, label: w.name }))}
value={selectedWorkflowId}
onChange={(v: string) => setSelectedWorkflowId(v)}
/>
{notDeployed && (
<p className='mt-1.5 text-[var(--text-error)] text-caption'>
This workflow isnt deployed. Deploy it first, then publish it as a block.
</p>
)}
</SettingRow>
</>
)}
{!isCreate && existing && (
<>
<SettingRow label='Workspace'>
<ChipInput value={existing.workspaceName ?? '—'} onChange={() => {}} disabled />
</SettingRow>
<SettingRow label='Workflow' description='The source workflow cant be changed.'>
<ChipInput value={existing.workflowName} onChange={() => {}} disabled />
{notDeployed && (
<p className='mt-1.5 text-[var(--text-error)] text-caption'>
This workflow isnt deployed. Redeploy it so the block can run.
</p>
)}
</SettingRow>
</>
)}
<SettingRow label='Icon' labelTooltip='Square image (PNG, JPEG, or SVG). Optional.'>
<div className='flex items-center gap-4'>
<DropZone onDrop={canManageBlock ? iconUpload.handleFileDrop : () => {}}>
<button
type='button'
onClick={iconUpload.handleThumbnailClick}
disabled={iconUpload.isUploading || !canManageBlock}
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{iconUpload.isUploading ? (
<Loader className='size-5 text-[var(--text-muted)]' animate />
) : iconUrl ? (
<img src={iconUrl} alt='' className='size-full object-contain p-1.5' />
) : (
<ImageIcon className='size-5 text-[var(--text-muted)]' />
)}
</button>
</DropZone>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={iconUpload.handleThumbnailClick}
disabled={iconUpload.isUploading || !canManageBlock}
className='text-[13px]'
>
{iconUrl ? 'Change' : 'Upload'}
</Button>
{iconUrl && (
<Button
type='button'
variant='ghost'
size='sm'
onClick={iconUpload.handleRemove}
disabled={iconUpload.isUploading || !canManageBlock}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
</Button>
)}
</div>
<input
ref={iconUpload.fileInputRef}
type='file'
accept={ICON_ACCEPT}
className='hidden'
onChange={iconUpload.handleFileChange}
/>
</div>
</SettingRow>
<SettingRow label='Name'>
<ChipInput
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='Invoice Parser'
maxLength={60}
disabled={!canManageBlock}
/>
</SettingRow>
<SettingRow label='Description'>
<ChipTextarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder='What this block does'
rows={2}
maxLength={280}
disabled={!canManageBlock}
/>
</SettingRow>
<SettingRow
label='Inputs'
description='Every Start input is exposed. Add a placeholder for each; its own description carries over.'
>
{deployed.isLoading ? (
<p className='text-[var(--text-muted)] text-small'>Loading workflow</p>
) : visibleInputs.length === 0 ? (
<p className='text-[var(--text-muted)] text-small'>
This workflows Start block has no inputs.
</p>
) : (
<div className='flex flex-col gap-2'>
{visibleInputs.map((i) => {
const expanded = expandedInputs.has(i.id)
return (
<div
key={i.id}
className='overflow-hidden rounded-sm border border-[var(--border-1)]'
>
<div
role='button'
tabIndex={0}
className='flex cursor-pointer items-center justify-between bg-[var(--surface-4)] px-2.5 py-[5px]'
onClick={() => toggleInput(i.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleInput(i.id)
}
}}
>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<span className='block truncate font-medium text-[var(--text-tertiary)] text-sm'>
{i.name}
</span>
<Badge variant='type' size='sm'>
{i.type}
</Badge>
</div>
<ChevronDown
className={cn(
'size-[14px] shrink-0 text-[var(--text-muted)] transition-transform',
expanded && 'rotate-180'
)}
/>
</div>
<Expandable expanded={expanded}>
<ExpandableContent>
<div className='flex flex-col gap-2 border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 pt-1.5 pb-2.5'>
{i.description && (
<div className='flex flex-col gap-1.5'>
<Label>Description</Label>
<p className='text-[var(--text-muted)] text-caption'>
{i.description}
</p>
</div>
)}
<div className='flex items-center justify-between'>
<Label htmlFor={`input-required-${i.id}`}>Required</Label>
<Switch
id={`input-required-${i.id}`}
checked={i.required ?? false}
onCheckedChange={(checked) =>
setInputOverride(i.id, { required: checked })
}
disabled={!canManageBlock}
/>
</div>
<div className='flex flex-col gap-1.5'>
<Label>Placeholder</Label>
<ChipInput
value={i.placeholder ?? ''}
onChange={(e) =>
setInputOverride(i.id, { placeholder: e.target.value })
}
placeholder='Shown in the empty field'
maxLength={200}
disabled={!canManageBlock}
/>
</div>
</div>
</ExpandableContent>
</Expandable>
</div>
)
})}
</div>
)}
</SettingRow>
<SettingRow
label='Outputs'
description='Pick which workflow outputs consumers see and name each one. At least one is required.'
>
<ChipCombobox
multiSelect
searchable
searchPlaceholder='Search outputs…'
className='w-full'
dropdownWidth='trigger'
maxHeight={280}
disabled={deployed.isLoading || outputGroups.length === 0 || !canManageBlock}
emptyMessage={deployed.isLoading ? 'Loading workflow…' : 'No outputs found.'}
options={[]}
groups={outputGroups}
multiSelectValues={selectedOutputKeys}
onMultiSelectChange={handleOutputsChange}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>
{visibleOutputs.length === 0
? 'Select outputs'
: `${visibleOutputs.length} selected`}
</span>
}
/>
{visibleOutputs.length > 0 && (
<div className='mt-2 flex flex-col gap-2'>
{visibleOutputs.map((o) => {
const key = encodeOutput(o.blockId, o.path)
return (
<div key={key} className='flex items-center gap-2'>
<span
className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-caption'
title={labelByKey.get(key) ?? o.path}
>
{labelByKey.get(key) ?? o.path}
</span>
<ChipInput
value={o.name}
onChange={(e) => setOutputName(key, e.target.value)}
placeholder='name'
className='w-[140px]'
maxLength={60}
disabled={!canManageBlock}
/>
</div>
)
})}
</div>
)}
</SettingRow>
</div>
</SettingsPanel>
<ChipConfirmModal
open={showDelete}
onOpenChange={() => setShowDelete(false)}
srTitle='Delete custom block'
title='Delete custom block'
text={[
'Delete ',
{ text: existing?.name ?? 'this block', bold: true },
'? This cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
pending: remove.isPending,
pendingLabel: 'Deleting...',
disabled: confirmationText !== (existing?.name ?? ''),
}}
>
{usageCountsQuery.data &&
(usageCount > 0 ? (
<p className='break-words px-2 text-[var(--text-error)] text-sm'>
{usageCount} {usageCount === 1 ? 'workflow is' : 'workflows are'} still using this
block and will fail at run time.
</p>
) : (
<p className='break-words px-2 text-[var(--text-muted)] text-sm'>
No workflows are currently using it.
</p>
))}
<ChipModalField
type='input'
title={
<span>
Type&nbsp;
<span className='font-medium text-[var(--text-primary)]'>{existing?.name}</span>
&nbsp;to confirm
</span>
}
value={confirmationText}
onChange={setConfirmationText}
placeholder={existing?.name}
/>
</ChipConfirmModal>
<UnsavedChangesModal
open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal}
onDiscard={guard.confirmDiscard}
/>
</>
)
}
/** Seed the editable inputs buffer from a block's (live-derived) input fields. */
function toCustomBlockInputs(
fields:
| ReadonlyArray<{
id?: string
name: string
type: string
placeholder?: string
description?: string
required?: boolean
}>
| undefined
): CustomBlockInput[] {
return (fields ?? []).map((f) => ({
id: f.id ?? f.name,
name: f.name,
type: f.type,
placeholder: f.placeholder,
description: f.description,
required: f.required,
}))
}
/**
* Compare inputs by only the authored data — the field id, placeholder, and
* required flag. name/type/description are derived live from the deployed Start
* (not stored), so comparing them would flag the form dirty when only Start
* metadata drifted.
*/
function normalizeInputsForCompare(items: ReadonlyArray<Partial<CustomBlockInput>>) {
return items.map((i) => ({
id: i.id ?? i.name ?? '',
placeholder: i.placeholder ?? '',
required: i.required ?? false,
}))
}
@@ -0,0 +1,132 @@
'use client'
import { useMemo, useState } from 'react'
import { ChipTag } from '@sim/emcn'
import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
import { CustomBlockDetail } from '@/ee/custom-blocks/components/custom-block-detail'
import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel'
import { useCanPublishCustomBlock, useCustomBlocks } from '@/hooks/queries/custom-blocks'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
export function CustomBlocks() {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
const { data: canManage = false, isLoading } = useCanPublishCustomBlock(workspaceId)
const { data: blocks = [] } = useCustomBlocks(workspaceId)
const { data: workspaces = [] } = useWorkspacesQuery()
// Any org member can view the org's blocks, but publishing requires admin on a
// source workspace — the publish route enforces admin on the picked workspace.
const currentOrgId = useMemo(
() => workspaces.find((w) => w.id === workspaceId)?.organizationId ?? null,
[workspaces, workspaceId]
)
const canCreate = useMemo(
() =>
!!currentOrgId &&
workspaces.some((w) => w.organizationId === currentOrgId && w.permissions === 'admin'),
[workspaces, currentOrgId]
)
const { data: whitelabel } = useWhitelabelSettings(blocks[0]?.organizationId)
const fallbackIconUrl = whitelabel?.logoUrl ?? null
const [searchTerm, setSearchTerm] = useState('')
const [selected, setSelected] = useState<string | 'new' | null>(null)
const filtered = useMemo(() => {
if (!searchTerm.trim()) return blocks
const q = searchTerm.toLowerCase()
return blocks.filter((b) => b.name.toLowerCase().includes(q))
}, [blocks, searchTerm])
if (isLoading) return null
if (!canManage) {
return (
<SettingsEmptyState>
Custom blocks require an Enterprise plan. Contact your admin to enable them.
</SettingsEmptyState>
)
}
if (selected !== null && workspaceId) {
return (
<CustomBlockDetail
key={selected}
blockId={selected === 'new' ? null : selected}
workspaceId={workspaceId}
onBack={() => setSelected(null)}
/>
)
}
return (
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search custom blocks...',
}}
actions={
canCreate
? [
{
text: 'Create block',
icon: Plus,
variant: 'primary',
onSelect: () => setSelected('new'),
},
]
: []
}
>
<SettingsSection label={`Blocks (${blocks.length})`}>
{blocks.length === 0 ? (
<SettingsEmptyState variant='inline'>
{canCreate
? 'No custom blocks yet. Click "Create block" to publish a workflow as a block.'
: 'No custom blocks yet.'}
</SettingsEmptyState>
) : filtered.length === 0 ? (
<SettingsEmptyState variant='inline'>
No blocks found matching "{searchTerm}"
</SettingsEmptyState>
) : (
<div className='-mx-2 flex flex-col gap-y-0.5'>
{filtered.map((cb) => {
const Icon = getCustomBlockIcon(cb.iconUrl, fallbackIconUrl)
return (
<button
key={cb.id}
type='button'
onClick={() => setSelected(cb.id)}
className='w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<SettingsResourceRow
icon={<Icon />}
iconFill
title={cb.name}
description={cb.description || undefined}
trailing={
<div className='flex flex-shrink-0 items-center gap-2'>
{!cb.enabled && <ChipTag variant='gray'>Disabled</ChipTag>}
<ArrowRight className='size-4 text-[var(--text-icon)]' />
</div>
}
/>
</button>
)
})}
</div>
)}
</SettingsSection>
</SettingsPanel>
)
}