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
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:
@@ -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 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,
|
||||
}))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user