Files
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

812 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
}))
}