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
812 lines
31 KiB
TypeScript
812 lines
31 KiB
TypeScript
'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 isn’t 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 can’t be changed.'>
|
||
<ChipInput value={existing.workflowName} onChange={() => {}} disabled />
|
||
{notDeployed && (
|
||
<p className='mt-1.5 text-[var(--text-error)] text-caption'>
|
||
This workflow isn’t 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 workflow’s 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
|
||
<span className='font-medium text-[var(--text-primary)]'>{existing?.name}</span>
|
||
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,
|
||
}))
|
||
}
|