From 704bb22a79450b29b6d5875170b7d26cff1dfde1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 15:31:14 -0700 Subject: [PATCH 1/5] improvement(custom-blocks): usage visibility + type-to-confirm delete --- .../custom-blocks/[id]/authorize-manage.ts | 35 + apps/sim/app/api/custom-blocks/[id]/route.ts | 44 +- .../custom-blocks/[id]/usages/route.test.ts | 96 +++ .../api/custom-blocks/[id]/usages/route.ts | 26 + .../components/custom-block-detail.tsx | 639 +++++++++++------- apps/sim/hooks/queries/custom-blocks.ts | 23 + apps/sim/lib/api/contracts/custom-blocks.ts | 29 + .../lib/workflows/custom-blocks/operations.ts | 87 ++- scripts/check-api-validation-contracts.ts | 4 +- 9 files changed, 700 insertions(+), 283 deletions(-) create mode 100644 apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts create mode 100644 apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts create mode 100644 apps/sim/app/api/custom-blocks/[id]/usages/route.ts diff --git a/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts new file mode 100644 index 00000000000..11c241986e1 --- /dev/null +++ b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { getCustomBlockManageContext } from '@/lib/workflows/custom-blocks/operations' +import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' + +export type ManageContext = NonNullable>> + +/** + * Confirm the caller can manage (edit/delete) the block: admin of the block's + * SOURCE workflow's workspace — matching who could publish it. Org admins/owners + * hold admin on every org workspace, so they pass too; a workspace admin from a + * different workspace does not, so they cannot alter another workspace's block or + * its exposed outputs. + */ +export async function authorizeManage( + userId: string, + id: string +): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> { + const ctx = await getCustomBlockManageContext(id) + if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null } + + if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) { + return { + error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }), + ctx: null, + } + } + if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) { + return { + error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }), + ctx: null, + } + } + return { error: null, ctx } +} diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts index 14d7d06ee9f..8e01f794b85 100644 --- a/apps/sim/app/api/custom-blocks/[id]/route.ts +++ b/apps/sim/app/api/custom-blocks/[id]/route.ts @@ -9,51 +9,19 @@ import { } from '@/lib/api/contracts/custom-blocks' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CustomBlockValidationError, deleteCustomBlock, - getCustomBlockManageContext, + getCustomBlockUsages, updateCustomBlock, } from '@/lib/workflows/custom-blocks/operations' -import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage' const logger = createLogger('CustomBlockAPI') type RouteContext = { params: Promise<{ id: string }> } -/** - * Confirm the caller can manage (edit/delete) the block: admin of the block's - * SOURCE workflow's workspace — matching who could publish it. Org admins/owners - * hold admin on every org workspace, so they pass too; a workspace admin from a - * different workspace does not, so they cannot alter another workspace's block or - * its exposed outputs. - */ -type ManageContext = NonNullable>> - -async function authorizeManage( - userId: string, - id: string -): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> { - const ctx = await getCustomBlockManageContext(id) - if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null } - - if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) { - return { - error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }), - ctx: null, - } - } - if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) { - return { - error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }), - ctx: null, - } - } - return { error: null, ctx } -} - export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { const session = await getSession() if (!session?.user?.id) { @@ -115,6 +83,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou if (authz.error) return authz.error const { ctx } = authz + const usages = await getCustomBlockUsages(ctx.organizationId, ctx.type) await deleteCustomBlock(id) recordAudit({ workspaceId: ctx.sourceWorkspaceId, @@ -126,7 +95,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou resourceId: id, resourceName: ctx.name, description: `Unpublished custom block "${ctx.name}"`, - metadata: { organizationId: ctx.organizationId, type: ctx.type }, + metadata: { + organizationId: ctx.organizationId, + type: ctx.type, + usageCount: usages.length, + deployedUsageCount: usages.filter((u) => u.inActiveDeployment).length, + }, request, }) return NextResponse.json({ success: true as const }) diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts new file mode 100644 index 00000000000..7aacc1def19 --- /dev/null +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsFeatureEnabled: vi.fn(), + mockHasWorkspaceAdminAccess: vi.fn(), + mockOperations: { + getCustomBlockManageContext: vi.fn(), + getCustomBlockUsages: vi.fn(), + }, + })) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations) + +import { GET } from '@/app/api/custom-blocks/[id]/usages/route' + +const MANAGE_CONTEXT = { + organizationId: 'org-1', + sourceWorkspaceId: 'ws-1', + type: 'custom_block_abc123', + name: 'Invoice Parser', +} + +const USAGE = { + workflowId: 'wf-1', + workflowName: 'Billing Pipeline', + workspaceId: 'ws-2', + workspaceName: 'Finance', + isDeployed: true, + inLiveState: true, + inActiveDeployment: true, +} + +function callRoute(id = 'cb-1') { + return GET(createMockRequest('GET'), { params: Promise.resolve({ id }) }) +} + +describe('GET /api/custom-blocks/[id]/usages', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockIsFeatureEnabled.mockResolvedValue(true) + mockHasWorkspaceAdminAccess.mockResolvedValue(true) + mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT) + mockOperations.getCustomBlockUsages.mockResolvedValue([USAGE]) + }) + + it('returns 401 without a session', async () => { + mockGetSession.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(401) + }) + + it('returns 404 for an unknown block', async () => { + mockOperations.getCustomBlockManageContext.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(404) + }) + + it('returns 403 when the feature flag is off', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + const response = await callRoute() + expect(response.status).toBe(403) + }) + + it('returns 403 for a non-admin of the source workspace', async () => { + mockHasWorkspaceAdminAccess.mockResolvedValue(false) + const response = await callRoute() + expect(response.status).toBe(403) + expect(mockOperations.getCustomBlockUsages).not.toHaveBeenCalled() + }) + + it('returns the org-scoped usages for the block type', async () => { + const response = await callRoute() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ usages: [USAGE] }) + expect(mockOperations.getCustomBlockUsages).toHaveBeenCalledWith('org-1', 'custom_block_abc123') + }) +}) diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts new file mode 100644 index 00000000000..7ede2bd5850 --- /dev/null +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getCustomBlockUsagesContract } from '@/lib/api/contracts/custom-blocks' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCustomBlockUsages } from '@/lib/workflows/custom-blocks/operations' +import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage' + +type RouteContext = { params: Promise<{ id: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getCustomBlockUsagesContract, request, context) + if (!parsed.success) return parsed.response + + const authz = await authorizeManage(session.user.id, parsed.data.params.id) + if (authz.error) return authz.error + + const usages = await getCustomBlockUsages(authz.ctx.organizationId, authz.ctx.type) + return NextResponse.json({ usages }) +}) diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index d9d48a99eed..3aa2de015ab 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -7,6 +7,8 @@ import { ChipCombobox, ChipConfirmModal, ChipInput, + ChipModalField, + ChipSwitch, ChipTextarea, type ComboboxOptionGroup, cn, @@ -17,7 +19,9 @@ import { toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowLeft, ChevronDown, Image as ImageIcon, X } from 'lucide-react' +import { ArrowLeft, ArrowRight, ChevronDown, Image as ImageIcon, X } from 'lucide-react' +import { useRouter } from 'next/navigation' +import type { CustomBlockUsage } from '@/lib/api/contracts/custom-blocks' import { type FlattenOutputsBlockInput, type FlattenOutputsEdgeInput, @@ -28,12 +32,14 @@ import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/cr 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 { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' 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, + useCustomBlockUsages, useDeleteCustomBlock, usePublishCustomBlock, useUpdateCustomBlock, @@ -44,6 +50,7 @@ import { useWorkspacesQuery } from '@/hooks/queries/workspace' const OUTPUT_SEP = '::' const ICON_ACCEPT = 'image/png,image/jpeg,image/jpg,image/svg+xml,image/webp' +const NO_USAGES: CustomBlockUsage[] = [] const encodeOutput = (blockId: string, path: string) => `${blockId}${OUTPUT_SEP}${path}` const decodeOutput = (value: string) => { @@ -70,6 +77,7 @@ interface CustomBlockDetailProps { } export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockDetailProps) { + const router = useRouter() const isCreate = blockId === null const { data: blocks = [] } = useCustomBlocks(workspaceId) @@ -136,8 +144,37 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD ) const [outputs, setOutputs] = useState(() => existing?.exposedOutputs ?? []) const [error, setError] = useState(null) + const [tab, setTab] = useState<'configuration' | 'usage'>('configuration') 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('') + } + + // Usage feeds both the Usage tab and the delete warning, so it's warm + // before the modal opens. Server-gated on the same manage authz as delete. + const usagesQuery = useCustomBlockUsages(existing?.id, { enabled: canManageBlock }) + const usages = usagesQuery.data ?? NO_USAGES + const deployedUsageCount = usages.filter((u) => u.inActiveDeployment).length + + // Usage spans the whole org; rows in workspaces the user isn't a member of + // render non-clickable (not-allowed cursor instead of the navigate arrow). + const memberWorkspaceIds = useMemo(() => new Set(workspaces.map((w) => w.id)), [workspaces]) + + const groupedUsages = useMemo(() => { + const groups = new Map() + for (const u of usages) { + const group = groups.get(u.workspaceId) + if (group) group.workflows.push(u) + else groups.set(u.workspaceId, { workspaceName: u.workspaceName, workflows: [u] }) + } + return [...groups.entries()].map(([workspaceId, group]) => ({ workspaceId, ...group })) + }, [usages]) + // 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 @@ -422,259 +459,349 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD )} - {isCreate && ( + {!isCreate && ( + + )} + + {!isCreate && + tab === 'usage' && + (!canManageBlock ? ( +

+ Only admins of the block’s workspace can see usage. +

+ ) : usagesQuery.isLoading ? ( +

Loading usage…

+ ) : usages.length === 0 ? ( +

Not used by any workflows.

+ ) : ( +
+

+ {usages.length} {usages.length === 1 ? 'workflow' : 'workflows'} ·{' '} + {deployedUsageCount} deployed +

+
+ {groupedUsages.map((group) => ( + +
+ {group.workflows.map((u) => { + const isMember = memberWorkspaceIds.has(u.workspaceId) + const rowContent = ( + <> +
+ + {u.workflowName} + + + {u.inActiveDeployment + ? u.inLiveState + ? 'Deployed' + : 'Deployed only' + : 'Draft'} + +
+ {isMember && ( + + )} + + ) + return isMember ? ( + + ) : ( +
+ {rowContent} +
+ ) + })} +
+
+ ))} +
+
+ ))} + + {(isCreate || tab === 'configuration') && ( <> - - ({ value: w.id, label: w.name }))} - value={selectedWorkspaceId} - onChange={(v: string) => { - setSelectedWorkspaceId(v) - setSelectedWorkflowId('') - }} - /> + {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 && ( + + )} +
+ +
- - ({ value: w.id, label: w.name }))} - value={selectedWorkflowId} - onChange={(v: string) => setSelectedWorkflowId(v)} + + + setName(e.target.value)} + placeholder='Invoice Parser' + maxLength={60} + disabled={!canManageBlock} /> - {notDeployed && ( -

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

- )}
- - )} - {!isCreate && existing && ( - <> - - {}} disabled /> + + setDescription(e.target.value)} + placeholder='What this block does' + rows={2} + maxLength={280} + disabled={!canManageBlock} + /> - - {}} disabled /> - {notDeployed && ( -

- This workflow isn’t deployed. Redeploy it so the block can run. + + + {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} +

+
+ )} +
+ + setPlaceholder(i.id, e.target.value)} + placeholder='Shown in the empty field' + maxLength={200} + disabled={!canManageBlock} + /> +
+
+
+
+
+ ) + })} +
)}
- - )} - -
- {}}> - - -
- - {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} + + + {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} - - {i.type} - + setOutputName(key, e.target.value)} + placeholder='name' + className='w-[140px]' + maxLength={60} + disabled={!canManageBlock} + />
- -
- - -
- {i.description && ( -
- -

- {i.description} -

-
- )} -
- - setPlaceholder(i.id, 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} - /> -
- ) - })} -
- )} -
+ ) + })} +
+ )} + + + )}
@@ -686,15 +813,39 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD text={[ 'Delete ', { text: existing?.name ?? 'this block', bold: true }, - '? Workflows already using it will stop resolving it. This cannot be undone.', + '? ', + deployedUsageCount > 0 + ? { + text: `${deployedUsageCount} deployed ${ + deployedUsageCount === 1 ? 'workflow uses' : 'workflows use' + } this block and will fail at run time.`, + error: true, + } + : 'Workflows already using it will stop resolving it.', + ' This cannot be undone.', ]} confirm={{ label: 'Delete', onClick: handleDelete, pending: remove.isPending, pendingLabel: 'Deleting...', + disabled: confirmationText !== (existing?.name ?? ''), }} - /> + > + + Type  + {existing?.name} +  to confirm + + } + value={confirmationText} + onChange={setConfirmationText} + placeholder={existing?.name} + /> + [...customBlockKeys.all, 'list'] as const, list: (workspaceId?: string) => [...customBlockKeys.lists(), workspaceId ?? ''] as const, + usages: (id?: string) => [...customBlockKeys.all, 'usages', id ?? ''] as const, } interface CustomBlocksResult { @@ -53,6 +58,24 @@ export function useCanPublishCustomBlock(workspaceId?: string) { return useCustomBlocksQuery(workspaceId, (r) => r.enabled) } +async function fetchCustomBlockUsages( + id: string, + signal?: AbortSignal +): Promise { + const data = await requestJson(getCustomBlockUsagesContract, { params: { id }, signal }) + return data.usages +} + +/** Workflows across the org that place this block (live editor state and/or active deployment). */ +export function useCustomBlockUsages(blockId?: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: customBlockKeys.usages(blockId), + queryFn: ({ signal }) => fetchCustomBlockUsages(blockId as string, signal), + enabled: Boolean(blockId) && (options?.enabled ?? true), + staleTime: CUSTOM_BLOCK_USAGES_STALE_TIME, + }) +} + export function usePublishCustomBlock(workspaceId?: string) { const queryClient = useQueryClient() return useMutation({ diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index 95d0915bca2..78658637803 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -93,6 +93,25 @@ export const updateCustomBlockBodySchema = z export type UpdateCustomBlockBody = z.input +/** + * A workflow in the org that places this block. Live editor state and the + * active deployment snapshot can diverge, so both placements are reported. + */ +export const customBlockUsageSchema = z.object({ + workflowId: z.string(), + workflowName: z.string(), + workspaceId: z.string(), + workspaceName: z.string(), + /** The consuming workflow is currently deployed. */ + isDeployed: z.boolean(), + /** Placed in the workflow's live editor state. */ + inLiveState: z.boolean(), + /** Present in the workflow's ACTIVE deployment snapshot — the state that actually runs. */ + inActiveDeployment: z.boolean(), +}) + +export type CustomBlockUsage = z.output + export const listCustomBlocksContract = defineRouteContract({ method: 'GET', path: '/api/custom-blocks', @@ -137,3 +156,13 @@ export const deleteCustomBlockContract = defineRouteContract({ schema: z.object({ success: z.literal(true) }), }, }) + +export const getCustomBlockUsagesContract = defineRouteContract({ + method: 'GET', + path: '/api/custom-blocks/[id]/usages', + params: customBlockIdParamsSchema, + response: { + mode: 'json', + schema: z.object({ usages: z.array(customBlockUsageSchema) }), + }, +}) diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 28d7dc5af35..d0357a93d39 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -1,8 +1,14 @@ import { db } from '@sim/db' -import { customBlock, workflow, workspace } from '@sim/db/schema' +import { + customBlock, + workflow, + workflowBlocks, + workflowDeploymentVersion, + workspace, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format' @@ -432,3 +438,80 @@ export async function updateCustomBlock( export async function deleteCustomBlock(id: string): Promise { await db.delete(customBlock).where(eq(customBlock.id, id)) } + +/** A workflow in the org that places a custom block. */ +export interface CustomBlockUsageRow { + workflowId: string + workflowName: string + workspaceId: string + workspaceName: string + isDeployed: boolean + inLiveState: boolean + inActiveDeployment: boolean +} + +/** + * Every non-archived workflow in the org that places the block, in its live + * editor state and/or its ACTIVE deployment snapshot. The two are scanned + * independently — a block removed in the editor can still ship in the active + * deployment (and vice versa), and the deployed placement is the one that + * actually runs. The deployment scan pre-filters with a raw-text match on the + * unique type slug so only near-exact matches pay the jsonb parse. + */ +export async function getCustomBlockUsages( + organizationId: string, + blockType: string +): Promise { + const meta = { + workflowId: workflow.id, + workflowName: workflow.name, + workspaceId: workspace.id, + workspaceName: workspace.name, + isDeployed: workflow.isDeployed, + } + const orgActiveWorkflow = and( + eq(workspace.organizationId, organizationId), + isNull(workflow.archivedAt) + ) + + const [liveRows, deployedRows] = await Promise.all([ + db + .selectDistinct(meta) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) + .innerJoin(workspace, eq(workspace.id, workflow.workspaceId)) + .where(and(eq(workflowBlocks.type, blockType), orgActiveWorkflow)), + db + .select(meta) + .from(workflowDeploymentVersion) + .innerJoin(workflow, eq(workflow.id, workflowDeploymentVersion.workflowId)) + .innerJoin(workspace, eq(workspace.id, workflow.workspaceId)) + .where( + and( + eq(workflowDeploymentVersion.isActive, true), + eq(workflow.isDeployed, true), + orgActiveWorkflow, + sql`${workflowDeploymentVersion.state}::text LIKE ${`%${blockType}%`}`, + sql`EXISTS ( + SELECT 1 FROM jsonb_each((${workflowDeploymentVersion.state})::jsonb -> 'blocks') AS b + WHERE b.value ->> 'type' = ${blockType} + )` + ) + ), + ]) + + const byWorkflowId = new Map() + for (const row of liveRows) { + byWorkflowId.set(row.workflowId, { ...row, inLiveState: true, inActiveDeployment: false }) + } + for (const row of deployedRows) { + const existing = byWorkflowId.get(row.workflowId) + if (existing) existing.inActiveDeployment = true + else byWorkflowId.set(row.workflowId, { ...row, inLiveState: false, inActiveDeployment: true }) + } + + return [...byWorkflowId.values()].sort( + (a, b) => + a.workspaceName.localeCompare(b.workspaceName) || a.workflowName.localeCompare(b.workflowName) + ) +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 9b54931dd94..d8694b3f6bc 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 926, - zodRoutes: 926, + totalRoutes: 927, + zodRoutes: 927, nonZodRoutes: 0, } as const From da9cb5aed0c66b48e7863d0850373c32e113116a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 16:13:13 -0700 Subject: [PATCH 2/5] feat(custom-blocks): per-input required option --- apps/sim/blocks/custom/build-config.ts | 4 ++ .../components/custom-block-detail.tsx | 64 ++++++++++++------ .../workflow/workflow-handler.test.ts | 65 +++++++++++++++++++ .../handlers/workflow/workflow-handler.ts | 60 +++++++++++++++++ apps/sim/lib/api/contracts/custom-blocks.ts | 11 ++-- .../lib/workflows/custom-blocks/operations.ts | 27 +++++--- apps/sim/lib/workflows/input-format.ts | 5 ++ packages/db/schema.ts | 13 ++-- 8 files changed, 211 insertions(+), 38 deletions(-) diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts index d13139aaba7..62cc473e7c9 100644 --- a/apps/sim/blocks/custom/build-config.ts +++ b/apps/sim/blocks/custom/build-config.ts @@ -33,6 +33,7 @@ export interface CustomBlockInput { type: string placeholder?: string description?: string + required?: boolean } /** @@ -108,6 +109,9 @@ export function buildCustomBlockConfig( type, description: field.description, placeholder: field.placeholder, + // Serializer Loop-B (required subBlocks not covered by tool params) and the + // editor asterisk both read this — same enforcement path as regular blocks. + required: field.required === true, } if (field.type === 'object' || field.type === 'array') sub.language = 'json' if (field.type === 'file[]') sub.multiple = true diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 3aa2de015ab..1709ace8f89 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -16,6 +16,7 @@ import { ExpandableContent, Label, Loader, + Switch, toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' @@ -214,15 +215,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD return m }, [availableFields]) - const placeholderById = useMemo(() => { - const m = new Map() - for (const i of inputs) m.set(i.id, i.placeholder) + 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); the only - // thing authored here is the placeholder. + // inherited from the field itself (the Start block already defines them); only + // the placeholder and required flag are authored here. const visibleInputs = useMemo( () => deployedLoaded @@ -233,11 +234,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD name: f.name, type: f.type, description: f.description, - placeholder: placeholderById.get(id), + placeholder: overrideById.get(id)?.placeholder, + required: overrideById.get(id)?.required, } }) : inputs, - [deployedLoaded, availableFields, placeholderById, inputs] + [deployedLoaded, availableFields, overrideById, inputs] ) const [expandedInputs, setExpandedInputs] = useState>(new Set()) @@ -318,15 +320,18 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD deployed.isLoading || (deployedLoaded && visibleOutputs.length === 0) - // Upsert the per-input placeholder (the only authored field). `visibleInputs` + // Upsert an authored per-input override (placeholder/required). `visibleInputs` // shows every deployed field; the first edit of a field adds its override row. - function setPlaceholder(id: string, placeholder: string) { + function setInputOverride( + id: string, + patch: Partial> + ) { setInputs((prev) => { if (prev.some((i) => i.id === id)) { - return prev.map((i) => (i.id === id ? { ...i, placeholder } : i)) + 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', placeholder }] + return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', ...patch }] }) } @@ -377,11 +382,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD setError('Output names must be unique') return } - // Only the placeholder is authored; the field set/name/type are always derived - // from the deployed Start. Persist just the non-empty placeholder overrides. + // 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()) - .map((i) => ({ id: i.id, placeholder: i.placeholder!.trim() })) + .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) { @@ -729,11 +738,24 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD

)} +
+ + + setInputOverride(i.id, { required: checked }) + } + disabled={!canManageBlock} + /> +
setPlaceholder(i.id, e.target.value)} + onChange={(e) => + setInputOverride(i.id, { placeholder: e.target.value }) + } placeholder='Shown in the empty field' maxLength={200} disabled={!canManageBlock} @@ -865,6 +887,7 @@ function toCustomBlockInputs( type: string placeholder?: string description?: string + required?: boolean }> | undefined ): CustomBlockInput[] { @@ -874,17 +897,20 @@ function toCustomBlockInputs( type: f.type, placeholder: f.placeholder, description: f.description, + required: f.required, })) } /** - * Compare inputs by only the authored data — the field id and its placeholder. - * 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. + * 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, })) } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index ffda0c65a2b..b650cc1059a 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -2,6 +2,7 @@ import { setupGlobalFetchMock } from '@sim/testing' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { + findMissingRequiredCustomBlockInputs, remapCustomBlockInputKeys, WorkflowBlockHandler, } from '@/executor/handlers/workflow/workflow-handler' @@ -432,3 +433,67 @@ describe('remapCustomBlockInputKeys', () => { ).toEqual({ payload: 'not json' }) }) }) + +describe('findMissingRequiredCustomBlockInputs', () => { + const childBlocks = { + start: { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [ + { id: 'f1', name: 'firstName', type: 'string' }, + { id: 'f2', name: 'payload', type: 'object' }, + { name: 'legacyField', type: 'string' }, + ], + }, + }, + }, + } as Record + + it('flags a required field left empty and reports its display name', () => { + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, {})).toEqual(['firstName']) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: '' })).toEqual([ + 'firstName', + ]) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: null })).toEqual([ + 'firstName', + ]) + }) + + it('passes when the required field has a value', () => { + expect( + findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'Theodore' }) + ).toEqual([]) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 0 })).toEqual([]) + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: false })).toEqual( + [] + ) + }) + + it('ignores a stale required override whose field was removed from the Start', () => { + expect(findMissingRequiredCustomBlockInputs(['removed-field'], childBlocks, {})).toEqual([]) + }) + + it('treats fields without an override as optional', () => { + expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'x' })).toEqual( + [] + ) + expect(findMissingRequiredCustomBlockInputs([], childBlocks, {})).toEqual([]) + }) + + it('keys legacy fields without a stable id by name', () => { + expect(findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, {})).toEqual([ + 'legacyField', + ]) + expect( + findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, { legacyField: 'v' }) + ).toEqual([]) + }) + + it('reports every missing required field at once', () => { + expect(findMissingRequiredCustomBlockInputs(['f1', 'f2'], childBlocks, {})).toEqual([ + 'firstName', + 'payload', + ]) + }) +}) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 70d1e52e089..edde6925eb5 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -46,6 +46,41 @@ function getValueAtPath(source: unknown, path: string): unknown { * name. Legacy fields without an id are keyed by name and pass through unchanged. * Keys that match no current field are dropped. */ +/** + * A consumer left a publisher-required custom block input empty. The message is + * consumer-safe (it names only the block's own input labels), so the catch's + * custom-block sanitizer rethrows it verbatim instead of the generic failure. + */ +export class CustomBlockMissingInputsError extends Error { + constructor(message: string) { + super(message) + this.name = 'CustomBlockMissingInputsError' + } +} + +/** + * Names of publisher-required custom block inputs the consumer left empty, checked + * against the child's LIVE deployed Start fields — a required override whose field + * was removed is inert, and a field added after publish has no override, so schema + * drift can never block a run. `childWorkflowInput` is the post-remap mapping + * (keyed by field name). Same empty semantics as the serializer's required check. + */ +export function findMissingRequiredCustomBlockInputs( + requiredInputIds: string[], + childBlocks: Record, + childWorkflowInput: Record +): string[] { + if (requiredInputIds.length === 0) return [] + const requiredIds = new Set(requiredInputIds) + return extractInputFieldsFromBlocks(childBlocks) + .filter((field) => requiredIds.has(field.id ?? field.name)) + .filter((field) => { + const value = childWorkflowInput[field.name] + return value === undefined || value === null || value === '' + }) + .map((field) => field.name) +} + export function remapCustomBlockInputKeys( mapping: Record, childBlocks: Record @@ -162,6 +197,7 @@ export class WorkflowBlockHandler implements BlockHandler { let workflowId = inputs.workflowId let loadUserId = ctx.userId let exposedOutputs: CustomBlockOutput[] = [] + let requiredInputIds: string[] = [] if (isCustomBlock) { const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId) if (!authority) { @@ -170,6 +206,7 @@ export class WorkflowBlockHandler implements BlockHandler { workflowId = authority.workflowId loadUserId = authority.ownerUserId exposedOutputs = authority.exposedOutputs + requiredInputIds = authority.requiredInputIds } if (!workflowId) { @@ -270,6 +307,19 @@ export class WorkflowBlockHandler implements BlockHandler { childWorkflowInput = inputs.input } + if (isCustomBlock) { + const missing = findMissingRequiredCustomBlockInputs( + requiredInputIds, + childWorkflow.rawBlocks || {}, + childWorkflowInput + ) + if (missing.length > 0) { + throw new CustomBlockMissingInputsError( + `${block.metadata?.name || 'Custom block'} is missing required fields: ${missing.join(', ')}` + ) + } + } + const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication( workflowId, childWorkflow.workflowState @@ -406,6 +456,16 @@ export class WorkflowBlockHandler implements BlockHandler { // so capture the child's spans server-side, distill to the aggregate cost, and // carry only that (no internals) so `block-executor` still bills it. if (isCustomBlock) { + // Missing-required-inputs is the consumer's own mistake and its message + // names only the block's input labels — surface it instead of the generic + // failure so they can actually fix it. The child never ran: no spend. + if (error instanceof CustomBlockMissingInputsError) { + throw new ChildWorkflowError({ + message: error.message, + childWorkflowName: block.metadata?.name || 'Custom block', + childWorkflowInstanceId: instanceId, + }) + } let failedChildSpans: WorkflowTraceSpan[] = [] if (hasExecutionResult(error) && error.executionResult.logs) { failedChildSpans = this.captureChildWorkflowLogs( diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index 78658637803..c8f8030abc3 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -11,17 +11,20 @@ const inputFieldSchema = z.object({ description: z.string().optional(), /** Consumer-facing placeholder hint (curated inputs only). */ placeholder: z.string().optional(), + /** Consumers must fill this input (curated inputs only). */ + required: z.boolean().optional(), }) /** - * The only authored per-input datum: a placeholder, keyed by the source Start - * field's stable `id`. The field's name/type/description are NOT stored — they're - * always derived from the live deployed Start (so they can't go stale), and this - * map only supplies the consumer-facing placeholder hint. + * The authored per-input data: a placeholder and a required flag, keyed by the + * source Start field's stable `id`. The field's name/type/description are NOT + * stored — they're always derived from the live deployed Start (so they can't go + * stale); an override whose field was removed from the Start is silently ignored. */ const inputPlaceholderSchema = z.object({ id: z.string().min(1), placeholder: z.string().max(200).optional(), + required: z.boolean().optional(), }) export type CustomBlockInputPlaceholder = z.input diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index d0357a93d39..4ee7a9ad6b8 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -68,15 +68,15 @@ async function deriveInputFields(workflowId: string): Promise [p.id, p.placeholder])) - // Placeholders are stored under `field.id ?? field.name` (the form's key), so a + const byId = new Map(placeholders.map((p) => [p.id, p])) + // Overrides are stored under `field.id ?? field.name` (the form's key), so a // legacy field with no stable id is keyed by name — look it up the same way. return deployed.map((field) => { - const placeholder = byId.get(field.id ?? field.name) - return placeholder ? { ...field, placeholder } : field + const override = byId.get(field.id ?? field.name) + if (!override) return field + return { + ...field, + ...(override.placeholder ? { placeholder: override.placeholder } : {}), + ...(override.required ? { required: true } : {}), + } }) } @@ -256,6 +261,8 @@ export async function getCustomBlockAuthority( organizationId: string ownerUserId: string exposedOutputs: CustomBlockOutput[] + /** Start-field ids (form keys) the publisher marked required. May reference removed fields. */ + requiredInputIds: string[] } | null> { // Scope resolution to the consumer's org: `(organizationId, type)` is the unique // key, so without the org filter a `custom_block_*` type smuggled in from another @@ -273,6 +280,7 @@ export async function getCustomBlockAuthority( organizationId: customBlock.organizationId, enabled: customBlock.enabled, outputs: customBlock.outputs, + inputs: customBlock.inputs, ownerUserId: workflow.userId, }) .from(customBlock) @@ -288,6 +296,7 @@ export async function getCustomBlockAuthority( organizationId: row.organizationId, ownerUserId: row.ownerUserId, exposedOutputs: row.outputs ?? [], + requiredInputIds: (row.inputs ?? []).filter((i) => i.required).map((i) => i.id), } } diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 97a63bc7e96..1f73780f65d 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -22,6 +22,11 @@ export interface WorkflowInputField { * in the Custom Blocks settings UI; has no source on the workflow's Start block. */ placeholder?: string + /** + * Consumers must fill this custom-block input. Authored in the Custom Blocks + * settings UI; has no source on the workflow's Start block. + */ + required?: boolean } /** diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 8028d3a3a10..d18762a071e 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2893,13 +2893,14 @@ export const customBlock = pgTable( /** Uploaded icon image URL (workspace storage), or null for the default icon. */ iconUrl: text('icon_url'), /** - * Per-input placeholder hints keyed by the source Start field's stable `id`: - * `Array<{ id, placeholder? }>`. Only the placeholder is authored — the input - * field set and its name/type/description are always derived live from the - * deployed Start (so they can never go stale). Absent/empty → no placeholder - * overrides; every deployed Start input is still exposed. + * Per-input authored overrides keyed by the source Start field's stable `id`: + * `Array<{ id, placeholder?, required? }>`. Only the placeholder and required + * flag are authored — the input field set and its name/type/description are + * always derived live from the deployed Start (so they can never go stale); an + * override whose field was removed is ignored. Absent/empty → no overrides; + * every deployed Start input is still exposed. */ - inputs: json('inputs').$type>(), + inputs: json('inputs').$type>(), /** * Curated outputs exposed to consumers: `Array<{ blockId, path, name }>`. Each * maps a child-workflow block output (blockId + dot-path) to a friendly output From 416a640ff1ce06929d3b7b95f05d4040b5961a23 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 16:33:31 -0700 Subject: [PATCH 3/5] improvement(custom-blocks): replace usage tab with delete-confirmation usage count --- apps/sim/app/api/custom-blocks/[id]/route.ts | 8 +- .../custom-blocks/[id]/usages/route.test.ts | 25 +- .../api/custom-blocks/[id]/usages/route.ts | 10 +- .../components/custom-block-detail.tsx | 653 ++++++++---------- apps/sim/hooks/queries/custom-blocks.ts | 19 +- apps/sim/lib/api/contracts/custom-blocks.ts | 25 +- .../lib/workflows/custom-blocks/operations.ts | 46 +- 7 files changed, 317 insertions(+), 469 deletions(-) diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts index 8e01f794b85..474d8ea273f 100644 --- a/apps/sim/app/api/custom-blocks/[id]/route.ts +++ b/apps/sim/app/api/custom-blocks/[id]/route.ts @@ -13,7 +13,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CustomBlockValidationError, deleteCustomBlock, - getCustomBlockUsages, + getCustomBlockUsageCounts, updateCustomBlock, } from '@/lib/workflows/custom-blocks/operations' import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage' @@ -83,7 +83,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou if (authz.error) return authz.error const { ctx } = authz - const usages = await getCustomBlockUsages(ctx.organizationId, ctx.type) + const usageCounts = await getCustomBlockUsageCounts(ctx.organizationId, ctx.type) await deleteCustomBlock(id) recordAudit({ workspaceId: ctx.sourceWorkspaceId, @@ -98,8 +98,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou metadata: { organizationId: ctx.organizationId, type: ctx.type, - usageCount: usages.length, - deployedUsageCount: usages.filter((u) => u.inActiveDeployment).length, + usageCount: usageCounts.usageCount, + deployedUsageCount: usageCounts.deployedUsageCount, }, request, }) diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts index 7aacc1def19..836f7960969 100644 --- a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts @@ -11,7 +11,7 @@ const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockO mockHasWorkspaceAdminAccess: vi.fn(), mockOperations: { getCustomBlockManageContext: vi.fn(), - getCustomBlockUsages: vi.fn(), + getCustomBlockUsageCounts: vi.fn(), }, })) @@ -38,15 +38,7 @@ const MANAGE_CONTEXT = { name: 'Invoice Parser', } -const USAGE = { - workflowId: 'wf-1', - workflowName: 'Billing Pipeline', - workspaceId: 'ws-2', - workspaceName: 'Finance', - isDeployed: true, - inLiveState: true, - inActiveDeployment: true, -} +const USAGE_COUNTS = { usageCount: 3, deployedUsageCount: 2 } function callRoute(id = 'cb-1') { return GET(createMockRequest('GET'), { params: Promise.resolve({ id }) }) @@ -59,7 +51,7 @@ describe('GET /api/custom-blocks/[id]/usages', () => { mockIsFeatureEnabled.mockResolvedValue(true) mockHasWorkspaceAdminAccess.mockResolvedValue(true) mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT) - mockOperations.getCustomBlockUsages.mockResolvedValue([USAGE]) + mockOperations.getCustomBlockUsageCounts.mockResolvedValue(USAGE_COUNTS) }) it('returns 401 without a session', async () => { @@ -84,13 +76,16 @@ describe('GET /api/custom-blocks/[id]/usages', () => { mockHasWorkspaceAdminAccess.mockResolvedValue(false) const response = await callRoute() expect(response.status).toBe(403) - expect(mockOperations.getCustomBlockUsages).not.toHaveBeenCalled() + expect(mockOperations.getCustomBlockUsageCounts).not.toHaveBeenCalled() }) - it('returns the org-scoped usages for the block type', async () => { + it('returns the org-scoped usage counts for the block type', async () => { const response = await callRoute() expect(response.status).toBe(200) - expect(await response.json()).toEqual({ usages: [USAGE] }) - expect(mockOperations.getCustomBlockUsages).toHaveBeenCalledWith('org-1', 'custom_block_abc123') + expect(await response.json()).toEqual(USAGE_COUNTS) + expect(mockOperations.getCustomBlockUsageCounts).toHaveBeenCalledWith( + 'org-1', + 'custom_block_abc123' + ) }) }) diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts index 7ede2bd5850..eee59b2c20a 100644 --- a/apps/sim/app/api/custom-blocks/[id]/usages/route.ts +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts @@ -1,10 +1,10 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { getCustomBlockUsagesContract } from '@/lib/api/contracts/custom-blocks' +import { getCustomBlockUsageCountsContract } from '@/lib/api/contracts/custom-blocks' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCustomBlockUsages } from '@/lib/workflows/custom-blocks/operations' +import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operations' import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage' type RouteContext = { params: Promise<{ id: string }> } @@ -15,12 +15,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const parsed = await parseRequest(getCustomBlockUsagesContract, request, context) + const parsed = await parseRequest(getCustomBlockUsageCountsContract, request, context) if (!parsed.success) return parsed.response const authz = await authorizeManage(session.user.id, parsed.data.params.id) if (authz.error) return authz.error - const usages = await getCustomBlockUsages(authz.ctx.organizationId, authz.ctx.type) - return NextResponse.json({ usages }) + const counts = await getCustomBlockUsageCounts(authz.ctx.organizationId, authz.ctx.type) + return NextResponse.json(counts) }) diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 1709ace8f89..44c643908f9 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -8,7 +8,6 @@ import { ChipConfirmModal, ChipInput, ChipModalField, - ChipSwitch, ChipTextarea, type ComboboxOptionGroup, cn, @@ -20,9 +19,7 @@ import { toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { ArrowLeft, ArrowRight, ChevronDown, Image as ImageIcon, X } from 'lucide-react' -import { useRouter } from 'next/navigation' -import type { CustomBlockUsage } from '@/lib/api/contracts/custom-blocks' +import { ArrowLeft, ChevronDown, Image as ImageIcon, X } from 'lucide-react' import { type FlattenOutputsBlockInput, type FlattenOutputsEdgeInput, @@ -33,14 +30,13 @@ import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/cr 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 { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' 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, - useCustomBlockUsages, + useCustomBlockUsageCounts, useDeleteCustomBlock, usePublishCustomBlock, useUpdateCustomBlock, @@ -51,7 +47,6 @@ import { useWorkspacesQuery } from '@/hooks/queries/workspace' const OUTPUT_SEP = '::' const ICON_ACCEPT = 'image/png,image/jpeg,image/jpg,image/svg+xml,image/webp' -const NO_USAGES: CustomBlockUsage[] = [] const encodeOutput = (blockId: string, path: string) => `${blockId}${OUTPUT_SEP}${path}` const decodeOutput = (value: string) => { @@ -78,7 +73,6 @@ interface CustomBlockDetailProps { } export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockDetailProps) { - const router = useRouter() const isCreate = blockId === null const { data: blocks = [] } = useCustomBlocks(workspaceId) @@ -145,7 +139,6 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD ) const [outputs, setOutputs] = useState(() => existing?.exposedOutputs ?? []) const [error, setError] = useState(null) - const [tab, setTab] = useState<'configuration' | 'usage'>('configuration') const [showDelete, setShowDelete] = useState(false) // Type-to-confirm buffer for the delete modal — reset whenever the modal opens. @@ -156,25 +149,10 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD if (showDelete) setConfirmationText('') } - // Usage feeds both the Usage tab and the delete warning, so it's warm - // before the modal opens. Server-gated on the same manage authz as delete. - const usagesQuery = useCustomBlockUsages(existing?.id, { enabled: canManageBlock }) - const usages = usagesQuery.data ?? NO_USAGES - const deployedUsageCount = usages.filter((u) => u.inActiveDeployment).length - - // Usage spans the whole org; rows in workspaces the user isn't a member of - // render non-clickable (not-allowed cursor instead of the navigate arrow). - const memberWorkspaceIds = useMemo(() => new Set(workspaces.map((w) => w.id)), [workspaces]) - - const groupedUsages = useMemo(() => { - const groups = new Map() - for (const u of usages) { - const group = groups.get(u.workspaceId) - if (group) group.workflows.push(u) - else groups.set(u.workspaceId, { workspaceName: u.workspaceName, workflows: [u] }) - } - return [...groups.entries()].map(([workspaceId, group]) => ({ workspaceId, ...group })) - }, [usages]) + // 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 @@ -468,362 +446,272 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
)} - {!isCreate && ( - - )} - - {!isCreate && - tab === 'usage' && - (!canManageBlock ? ( -

- Only admins of the block’s workspace can see usage. -

- ) : usagesQuery.isLoading ? ( -

Loading usage…

- ) : usages.length === 0 ? ( -

Not used by any workflows.

- ) : ( -
-

- {usages.length} {usages.length === 1 ? 'workflow' : 'workflows'} ·{' '} - {deployedUsageCount} deployed -

-
- {groupedUsages.map((group) => ( - -
- {group.workflows.map((u) => { - const isMember = memberWorkspaceIds.has(u.workspaceId) - const rowContent = ( - <> -
- - {u.workflowName} - - - {u.inActiveDeployment - ? u.inLiveState - ? 'Deployed' - : 'Deployed only' - : 'Draft'} - -
- {isMember && ( - - )} - - ) - return isMember ? ( - - ) : ( -
- {rowContent} -
- ) - })} -
-
- ))} -
-
- ))} - - {(isCreate || tab === 'configuration') && ( + {isCreate && ( <> - {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} + + ({ value: w.id, label: w.name }))} + value={selectedWorkspaceId} + onChange={(v: string) => { + setSelectedWorkspaceId(v) + setSelectedWorkflowId('') + }} /> - - - setDescription(e.target.value)} - placeholder='What this block does' - rows={2} - maxLength={280} - disabled={!canManageBlock} + + ({ value: w.id, label: w.name }))} + value={selectedWorkflowId} + onChange={(v: string) => setSelectedWorkflowId(v)} /> - - - - {deployed.isLoading ? ( -

Loading workflow…

- ) : visibleInputs.length === 0 ? ( -

- This workflow’s Start block has no inputs. + {notDeployed && ( +

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

- ) : ( -
- {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} - /> -
- ) - })} -
+ {!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} + /> +
+ ) + })} +
+ )} +
@@ -835,16 +723,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD text={[ 'Delete ', { text: existing?.name ?? 'this block', bold: true }, - '? ', - deployedUsageCount > 0 - ? { - text: `${deployedUsageCount} deployed ${ - deployedUsageCount === 1 ? 'workflow uses' : 'workflows use' - } this block and will fail at run time.`, - error: true, - } - : 'Workflows already using it will stop resolving it.', - ' This cannot be undone.', + '? This cannot be undone.', ]} confirm={{ label: 'Delete', @@ -854,6 +733,16 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD disabled: confirmationText !== (existing?.name ?? ''), }} > + {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. +

+ )} r.enabled) } -async function fetchCustomBlockUsages( +function fetchCustomBlockUsageCounts( id: string, signal?: AbortSignal -): Promise { - const data = await requestJson(getCustomBlockUsagesContract, { params: { id }, signal }) - return data.usages +): Promise { + return requestJson(getCustomBlockUsageCountsContract, { params: { id }, signal }) } -/** Workflows across the org that place this block (live editor state and/or active deployment). */ -export function useCustomBlockUsages(blockId?: string, options?: { enabled?: boolean }) { +/** How many workflows across the org place this block (live editor state and/or active deployment). */ +export function useCustomBlockUsageCounts(blockId?: string, options?: { enabled?: boolean }) { return useQuery({ queryKey: customBlockKeys.usages(blockId), - queryFn: ({ signal }) => fetchCustomBlockUsages(blockId as string, signal), + queryFn: ({ signal }) => fetchCustomBlockUsageCounts(blockId as string, signal), enabled: Boolean(blockId) && (options?.enabled ?? true), staleTime: CUSTOM_BLOCK_USAGES_STALE_TIME, }) diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index c8f8030abc3..01adbbddfc0 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -97,23 +97,16 @@ export const updateCustomBlockBodySchema = z export type UpdateCustomBlockBody = z.input /** - * A workflow in the org that places this block. Live editor state and the - * active deployment snapshot can diverge, so both placements are reported. + * How many workflows in the org place this block. Live editor state and the + * active deployment snapshot can diverge, so a workflow counts when the block + * appears in either; `deployedUsageCount` counts active deployments only. */ -export const customBlockUsageSchema = z.object({ - workflowId: z.string(), - workflowName: z.string(), - workspaceId: z.string(), - workspaceName: z.string(), - /** The consuming workflow is currently deployed. */ - isDeployed: z.boolean(), - /** Placed in the workflow's live editor state. */ - inLiveState: z.boolean(), - /** Present in the workflow's ACTIVE deployment snapshot — the state that actually runs. */ - inActiveDeployment: z.boolean(), +export const customBlockUsageCountsSchema = z.object({ + usageCount: z.number().int().min(0), + deployedUsageCount: z.number().int().min(0), }) -export type CustomBlockUsage = z.output +export type CustomBlockUsageCounts = z.output export const listCustomBlocksContract = defineRouteContract({ method: 'GET', @@ -160,12 +153,12 @@ export const deleteCustomBlockContract = defineRouteContract({ }, }) -export const getCustomBlockUsagesContract = defineRouteContract({ +export const getCustomBlockUsageCountsContract = defineRouteContract({ method: 'GET', path: '/api/custom-blocks/[id]/usages', params: customBlockIdParamsSchema, response: { mode: 'json', - schema: z.object({ usages: z.array(customBlockUsageSchema) }), + schema: customBlockUsageCountsSchema, }, }) diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 4ee7a9ad6b8..07f9726bb88 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -448,36 +448,18 @@ export async function deleteCustomBlock(id: string): Promise { await db.delete(customBlock).where(eq(customBlock.id, id)) } -/** A workflow in the org that places a custom block. */ -export interface CustomBlockUsageRow { - workflowId: string - workflowName: string - workspaceId: string - workspaceName: string - isDeployed: boolean - inLiveState: boolean - inActiveDeployment: boolean -} - /** - * Every non-archived workflow in the org that places the block, in its live - * editor state and/or its ACTIVE deployment snapshot. The two are scanned + * How many non-archived workflows in the org place the block, in their live + * editor state and/or their ACTIVE deployment snapshot. The two are scanned * independently — a block removed in the editor can still ship in the active * deployment (and vice versa), and the deployed placement is the one that * actually runs. The deployment scan pre-filters with a raw-text match on the * unique type slug so only near-exact matches pay the jsonb parse. */ -export async function getCustomBlockUsages( +export async function getCustomBlockUsageCounts( organizationId: string, blockType: string -): Promise { - const meta = { - workflowId: workflow.id, - workflowName: workflow.name, - workspaceId: workspace.id, - workspaceName: workspace.name, - isDeployed: workflow.isDeployed, - } +): Promise<{ usageCount: number; deployedUsageCount: number }> { const orgActiveWorkflow = and( eq(workspace.organizationId, organizationId), isNull(workflow.archivedAt) @@ -485,13 +467,13 @@ export async function getCustomBlockUsages( const [liveRows, deployedRows] = await Promise.all([ db - .selectDistinct(meta) + .selectDistinct({ workflowId: workflow.id }) .from(workflowBlocks) .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) .innerJoin(workspace, eq(workspace.id, workflow.workspaceId)) .where(and(eq(workflowBlocks.type, blockType), orgActiveWorkflow)), db - .select(meta) + .select({ workflowId: workflow.id }) .from(workflowDeploymentVersion) .innerJoin(workflow, eq(workflow.id, workflowDeploymentVersion.workflowId)) .innerJoin(workspace, eq(workspace.id, workflow.workspaceId)) @@ -509,18 +491,8 @@ export async function getCustomBlockUsages( ), ]) - const byWorkflowId = new Map() - for (const row of liveRows) { - byWorkflowId.set(row.workflowId, { ...row, inLiveState: true, inActiveDeployment: false }) - } - for (const row of deployedRows) { - const existing = byWorkflowId.get(row.workflowId) - if (existing) existing.inActiveDeployment = true - else byWorkflowId.set(row.workflowId, { ...row, inLiveState: false, inActiveDeployment: true }) - } + const usingWorkflowIds = new Set(liveRows.map((r) => r.workflowId)) + for (const row of deployedRows) usingWorkflowIds.add(row.workflowId) - return [...byWorkflowId.values()].sort( - (a, b) => - a.workspaceName.localeCompare(b.workspaceName) || a.workflowName.localeCompare(b.workflowName) - ) + return { usageCount: usingWorkflowIds.size, deployedUsageCount: deployedRows.length } } From df176702e4114fbc5a62fe82da5590633b2da8e2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 16:48:49 -0700 Subject: [PATCH 4/5] fix(custom-blocks): escape LIKE wildcards in usage scan + fresh count on delete modal --- .../components/custom-block-detail.tsx | 28 +++++++++++-------- .../lib/workflows/custom-blocks/operations.ts | 5 +++- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 44c643908f9..7eb676229d2 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -432,7 +432,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD { text: remove.isPending ? 'Deleting...' : 'Delete', variant: 'destructive' as const, - onSelect: () => setShowDelete(true), + onSelect: () => { + setShowDelete(true) + // The warning must reflect the org's CURRENT usage, not a + // ≤30s-stale cache entry. + usageCountsQuery.refetch() + }, disabled: remove.isPending, }, ] @@ -733,16 +738,17 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD disabled: confirmationText !== (existing?.name ?? ''), }} > - {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. -

- )} + {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. +

+ ))} ` would otherwise match + // any character and let unrelated states through to the jsonb parse. + const likePattern = `%${blockType.replace(/[\\%_]/g, '\\$&')}%` const [liveRows, deployedRows] = await Promise.all([ db @@ -482,7 +485,7 @@ export async function getCustomBlockUsageCounts( eq(workflowDeploymentVersion.isActive, true), eq(workflow.isDeployed, true), orgActiveWorkflow, - sql`${workflowDeploymentVersion.state}::text LIKE ${`%${blockType}%`}`, + sql`${workflowDeploymentVersion.state}::text LIKE ${likePattern}`, sql`EXISTS ( SELECT 1 FROM jsonb_each((${workflowDeploymentVersion.state})::jsonb -> 'blocks') AS b WHERE b.value ->> 'type' = ${blockType} From 093a5cf9bff4f376d71db9ac43f066dc4e7adf26 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 16:55:59 -0700 Subject: [PATCH 5/5] fix(custom-blocks): explicit ESCAPE clause on usage-scan LIKE prefilter --- apps/sim/lib/workflows/custom-blocks/operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 15072547c8d..23e602b83d1 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -485,7 +485,7 @@ export async function getCustomBlockUsageCounts( eq(workflowDeploymentVersion.isActive, true), eq(workflow.isDeployed, true), orgActiveWorkflow, - sql`${workflowDeploymentVersion.state}::text LIKE ${likePattern}`, + sql`${workflowDeploymentVersion.state}::text LIKE ${likePattern} ESCAPE '\\'`, sql`EXISTS ( SELECT 1 FROM jsonb_each((${workflowDeploymentVersion.state})::jsonb -> 'blocks') AS b WHERE b.value ->> 'type' = ${blockType}