'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 { 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(() => toCustomBlockInputs(existing?.inputFields) ) const [outputs, setOutputs] = useState(() => existing?.exposedOutputs ?? []) const [error, setError] = useState(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 } | null | undefined if (!state?.blocks) return [] return extractInputFieldsFromBlocks(state.blocks) }, [deployed.data]) const fieldById = useMemo(() => { const m = new Map() for (const f of availableFields) m.set(f.id ?? f.name, f) return m }, [availableFields]) const overrideById = useMemo(() => { const m = new Map() 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( () => 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>(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; edges?: FlattenOutputsEdgeInput[] } | null | undefined const labels = new Map() 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> ) { 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 ( <> 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, }, ] : []), ]} >
{error && (
{error}
)} {isCreate && ( <> ({ value: w.id, label: w.name }))} value={selectedWorkspaceId} onChange={(v: string) => { setSelectedWorkspaceId(v) setSelectedWorkflowId('') }} /> ({ value: w.id, label: w.name }))} value={selectedWorkflowId} onChange={(v: string) => setSelectedWorkflowId(v)} /> {notDeployed && (

This workflow isn’t deployed. Deploy it first, then publish it as a block.

)}
)} {!isCreate && existing && ( <> {}} disabled /> {}} disabled /> {notDeployed && (

This workflow isn’t deployed. Redeploy it so the block can run.

)}
)}
{}}>
{iconUrl && ( )}
setName(e.target.value)} placeholder='Invoice Parser' maxLength={60} disabled={!canManageBlock} /> setDescription(e.target.value)} placeholder='What this block does' rows={2} maxLength={280} disabled={!canManageBlock} /> {deployed.isLoading ? (

Loading workflow…

) : visibleInputs.length === 0 ? (

This workflow’s Start block has no inputs.

) : (
{visibleInputs.map((i) => { const expanded = expandedInputs.has(i.id) return (
toggleInput(i.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() toggleInput(i.id) } }} >
{i.name} {i.type}
{i.description && (

{i.description}

)}
setInputOverride(i.id, { required: checked }) } disabled={!canManageBlock} />
setInputOverride(i.id, { placeholder: e.target.value }) } placeholder='Shown in the empty field' maxLength={200} disabled={!canManageBlock} />
) })}
)}
{visibleOutputs.length === 0 ? 'Select outputs' : `${visibleOutputs.length} selected`} } /> {visibleOutputs.length > 0 && (
{visibleOutputs.map((o) => { const key = encodeOutput(o.blockId, o.path) return (
{labelByKey.get(key) ?? o.path} setOutputName(key, e.target.value)} placeholder='name' className='w-[140px]' maxLength={60} disabled={!canManageBlock} />
) })}
)}
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 ? (

{usageCount} {usageCount === 1 ? 'workflow is' : 'workflows are'} still using this block and will fail at run time.

) : (

No workflows are currently using it.

))} Type  {existing?.name}  to confirm } value={confirmationText} onChange={setConfirmationText} placeholder={existing?.name} />
) } /** 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>) { return items.map((i) => ({ id: i.id ?? i.name ?? '', placeholder: i.placeholder ?? '', required: i.required ?? false, })) }