diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 0743c63ea9..605cda4df8 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -790,6 +790,59 @@ input[type="search"]::-ms-clear { } } +@keyframes notification-enter { + from { + opacity: 0; + transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); + } + to { + opacity: 1; + transform: translateX(var(--stack-offset, 0px)) scale(1); + } +} + +@keyframes notification-countdown { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: 34.56; + } +} + +@keyframes notification-exit { + from { + opacity: 1; + transform: translateX(var(--stack-offset, 0px)) scale(1); + } + to { + opacity: 0; + transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); + } +} + +@keyframes toast-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes toast-exit { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.97); + } +} + /* WandPromptBar status indicator */ @keyframes smoke-pulse { 0%, diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 5abbde90ab..de24323dd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,6 +1,5 @@ 'use client' -import { ToastProvider } from '@/components/emcn' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' @@ -9,7 +8,7 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/side export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return ( - + <> @@ -26,6 +25,6 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index db46e4dd54..f46ff89538 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -1,8 +1,10 @@ -import { memo, useCallback, useEffect, useRef } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { toast, useToast } from '@/components/emcn' +import { X } from 'lucide-react' +import { Button, Tooltip } from '@/components/emcn' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' +import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { type Notification, type NotificationAction, @@ -12,6 +14,13 @@ import { import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Notifications') +const MAX_VISIBLE_NOTIFICATIONS = 4 +const STACK_OFFSET_PX = 3 +const AUTO_DISMISS_MS = 10000 +const EXIT_ANIMATION_MS = 200 + +const RING_RADIUS = 5.5 +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS const ACTION_LABELS: Record = { copilot: 'Fix in Copilot', @@ -19,99 +28,120 @@ const ACTION_LABELS: Record = { 'unlock-workflow': 'Unlock Workflow', } as const -function executeNotificationAction(action: NotificationAction) { - switch (action.type) { - case 'copilot': - openCopilotWithMessage(action.message) - break - case 'refresh': - window.location.reload() - break - case 'unlock-workflow': - window.dispatchEvent(new CustomEvent('unlock-workflow')) - break - default: - logger.warn('Unknown action type', { actionType: action.type }) - } +function isAutoDismissable(n: Notification): boolean { + return n.level === 'error' && !!n.workflowId } -function notificationToToast(n: Notification, removeNotification: (id: string) => void) { - const toastAction = n.action - ? { - label: ACTION_LABELS[n.action.type] ?? 'Take action', - onClick: () => { - executeNotificationAction(n.action!) - removeNotification(n.id) - }, - } - : undefined - - return { - message: n.message, - variant: n.level === 'error' ? ('error' as const) : ('default' as const), - action: toastAction, - duration: n.level === 'error' && n.workflowId ? 10_000 : 0, - } +function CountdownRing({ onPause }: { onPause: () => void }) { + return ( + + + + + +

Keep visible

+
+
+ ) } /** - * Headless bridge that syncs the notification Zustand store into the toast system. + * Notifications display component. + * Positioned in the bottom-right workspace area, reactive to panel width and terminal height. + * Shows both global notifications and workflow-specific notifications. * - * Watches for new notifications scoped to the active workflow and shows them as toasts. - * When a toast is dismissed, the corresponding notification is removed from the store. + * Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown + * ring. Clicking the ring pauses all timers until the notification stack clears. */ export const Notifications = memo(function Notifications() { const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const allNotifications = useNotificationStore((state) => state.notifications) const removeNotification = useNotificationStore((state) => state.removeNotification) const clearNotifications = useNotificationStore((state) => state.clearNotifications) - const { dismissAll } = useToast() - const shownIdsRef = useRef(new Set()) + const visibleNotifications = useMemo(() => { + if (!activeWorkflowId) return [] + return allNotifications + .filter((n) => !n.workflowId || n.workflowId === activeWorkflowId) + .slice(0, MAX_VISIBLE_NOTIFICATIONS) + }, [allNotifications, activeWorkflowId]) - const showNotification = useCallback( - (n: Notification) => { - if (shownIdsRef.current.has(n.id)) return - shownIdsRef.current.add(n.id) + /** + * Executes a notification action and handles side effects. + * + * @param notificationId - The ID of the notification whose action is executed. + * @param action - The action configuration to execute. + */ + const executeAction = useCallback( + (notificationId: string, action: NotificationAction) => { + try { + logger.info('Executing notification action', { + notificationId, + actionType: action.type, + messageLength: action.message.length, + }) - const input = notificationToToast(n, removeNotification) - toast(input) + switch (action.type) { + case 'copilot': + openCopilotWithMessage(action.message) + break + case 'refresh': + window.location.reload() + break + case 'unlock-workflow': + window.dispatchEvent(new CustomEvent('unlock-workflow')) + break + default: + logger.warn('Unknown action type', { notificationId, actionType: action.type }) + } - logger.info('Notification shown as toast', { - id: n.id, - level: n.level, - workflowId: n.workflowId, - }) + removeNotification(notificationId) + } catch (error) { + logger.error('Failed to execute notification action', { + notificationId, + actionType: action.type, + error, + }) + } }, [removeNotification] ) - useEffect(() => { - if (!activeWorkflowId) return - - const visible = allNotifications.filter( - (n) => !n.workflowId || n.workflowId === activeWorkflowId - ) - - for (const n of visible) { - showNotification(n) - } - - const currentIds = new Set(allNotifications.map((n) => n.id)) - for (const id of shownIdsRef.current) { - if (!currentIds.has(id)) { - shownIdsRef.current.delete(id) - } - } - }, [allNotifications, activeWorkflowId, showNotification]) - useRegisterGlobalCommands(() => createCommands([ { id: 'clear-notifications', handler: () => { clearNotifications(activeWorkflowId ?? undefined) - dismissAll() }, overrides: { allowInEditable: false, @@ -120,5 +150,144 @@ export const Notifications = memo(function Notifications() { ]) ) - return null + const preventZoomRef = usePreventZoom() + + const [isPaused, setIsPaused] = useState(false) + const isPausedRef = useRef(false) + const [exitingIds, setExitingIds] = useState>(new Set()) + const timersRef = useRef(new Map>()) + + const pauseAll = useCallback(() => { + setIsPaused(true) + isPausedRef.current = true + setExitingIds(new Set()) + for (const timer of timersRef.current.values()) clearTimeout(timer) + timersRef.current.clear() + }, []) + + /** + * Manages per-notification dismiss timers. + * Resets pause state when the notification stack empties so new arrivals get fresh timers. + */ + useEffect(() => { + isPausedRef.current = isPaused + }, [isPaused]) + + useEffect(() => { + if (visibleNotifications.length === 0) { + if (isPaused) setIsPaused(false) + for (const timer of timersRef.current.values()) clearTimeout(timer) + timersRef.current.clear() + return + } + if (isPaused) return + + const timers = timersRef.current + const activeIds = new Set() + + for (const n of visibleNotifications) { + if (!isAutoDismissable(n) || timers.has(n.id)) continue + activeIds.add(n.id) + + timers.set( + n.id, + setTimeout(() => { + timers.delete(n.id) + setExitingIds((prev) => new Set(prev).add(n.id)) + setTimeout(() => { + if (isPausedRef.current) return + removeNotification(n.id) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(n.id) + return next + }) + }, EXIT_ANIMATION_MS) + }, AUTO_DISMISS_MS) + ) + } + + for (const [id, timer] of timers) { + if (!activeIds.has(id) && !visibleNotifications.some((n) => n.id === id)) { + clearTimeout(timer) + timers.delete(id) + } + } + }, [visibleNotifications, removeNotification, isPaused]) + + useEffect(() => { + const timers = timersRef.current + return () => { + for (const timer of timers.values()) clearTimeout(timer) + } + }, []) + + if (visibleNotifications.length === 0) { + return null + } + + return ( +
+ {[...visibleNotifications].reverse().map((notification, index, stacked) => { + const depth = stacked.length - index - 1 + const xOffset = depth * STACK_OFFSET_PX + const hasAction = Boolean(notification.action) + const showCountdown = !isPaused && isAutoDismissable(notification) + + return ( +
+
+
+
+ {notification.level === 'error' && ( + + )} + {notification.message} +
+
+ {showCountdown && } + + + + + + Clear all + + +
+
+ {hasAction && ( + + )} +
+
+ ) + })} +
+ ) }) diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx index 3603e26364..965f6ede81 100644 --- a/apps/sim/components/emcn/components/toast/toast.tsx +++ b/apps/sim/components/emcn/components/toast/toast.tsx @@ -2,8 +2,6 @@ import { createContext, - memo, - type ReactElement, type ReactNode, useCallback, useContext, @@ -14,30 +12,11 @@ import { } from 'react' import { X } from 'lucide-react' import { createPortal } from 'react-dom' -import { Button } from '@/components/emcn/components/button/button' -import { Tooltip } from '@/components/emcn/components/tooltip/tooltip' +import { cn } from '@/lib/core/utils/cn' -const AUTO_DISMISS_MS = 10_000 +const AUTO_DISMISS_MS = 0 const EXIT_ANIMATION_MS = 200 -const MAX_VISIBLE = 4 -const STACK_OFFSET_PX = 3 - -const RING_RADIUS = 5.5 -const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS - -const TOAST_KEYFRAMES = ` -@keyframes toast-enter { - from { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); } - to { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); } -} -@keyframes toast-exit { - from { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); } - to { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); } -} -@keyframes toast-countdown { - from { stroke-dashoffset: 0; } - to { stroke-dashoffset: ${RING_CIRCUMFERENCE.toFixed(2)}; } -}` +const MAX_VISIBLE = 20 type ToastVariant = 'default' | 'success' | 'error' @@ -49,17 +28,16 @@ interface ToastAction { interface ToastData { id: string message: string + description?: string variant: ToastVariant - icon?: ReactElement action?: ToastAction duration: number - createdAt: number } type ToastInput = { message: string + description?: string variant?: ToastVariant - icon?: ReactElement action?: ToastAction duration?: number } @@ -73,7 +51,6 @@ type ToastFn = { interface ToastContextValue { toast: ToastFn dismiss: (id: string) => void - dismissAll: () => void } const ToastContext = createContext(null) @@ -113,133 +90,81 @@ export function useToast() { return ctx } -function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: () => void }) { - return ( - - - - - -

Keep visible

-
-
- ) +const VARIANT_STYLES: Record = { + default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]', + success: + 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200', + error: + 'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200', } -const ToastItem = memo(function ToastItem({ - data, - depth, - isExiting, - showCountdown, - onDismiss, - onPauseCountdown, - onAction, -}: { - data: ToastData - depth: number - isExiting: boolean - showCountdown: boolean - onDismiss: (id: string) => void - onPauseCountdown: () => void - onAction: (id: string) => void -}) { - const xOffset = depth * STACK_OFFSET_PX +function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) { + const [exiting, setExiting] = useState(false) + const timerRef = useRef>(undefined) + + const dismiss = useCallback(() => { + setExiting(true) + setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS) + }, [onDismiss, t.id]) + + useEffect(() => { + if (t.duration > 0) { + timerRef.current = setTimeout(dismiss, t.duration) + return () => clearTimeout(timerRef.current) + } + }, [dismiss, t.duration]) return (
-
-
- {data.icon && ( - - {data.icon} - - )} -
- {data.variant === 'error' && ( - - )} - {data.message} -
-
- {showCountdown && ( - - )} - -
-
- {data.action && ( - +
+

{t.message}

+ {t.description && ( +

{t.description}

)}
+ {t.action && ( + + )} +
) -}) +} /** * Toast container that renders toasts via portal. - * Mount once where you want toasts to appear. Renders stacked cards in the bottom-right. + * Mount once in your root layout. * - * Visual design matches the workflow notification component: 240px cards, stacked with - * offset, countdown ring on auto-dismissing items, enter/exit animations. + * @example + * ```tsx + * + * ``` */ export function ToastProvider({ children }: { children?: ReactNode }) { const [toasts, setToasts] = useState([]) const [mounted, setMounted] = useState(false) - const [isPaused, setIsPaused] = useState(false) - const [exitingIds, setExitingIds] = useState>(new Set()) - const timersRef = useRef(new Map>()) useEffect(() => { setMounted(true) @@ -250,87 +175,17 @@ export function ToastProvider({ children }: { children?: ReactNode }) { const data: ToastData = { id, message: input.message, + description: input.description, variant: input.variant ?? 'default', - icon: input.icon, action: input.action, duration: input.duration ?? AUTO_DISMISS_MS, - createdAt: Date.now(), } - setToasts((prev) => [data, ...prev].slice(0, MAX_VISIBLE)) + setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE)) return id }, []) const dismissToast = useCallback((id: string) => { - setExitingIds((prev) => new Set(prev).add(id)) - setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)) - setExitingIds((prev) => { - const next = new Set(prev) - next.delete(id) - return next - }) - }, EXIT_ANIMATION_MS) - }, []) - - const dismissAll = useCallback(() => { - setToasts([]) - setExitingIds(new Set()) - for (const timer of timersRef.current.values()) clearTimeout(timer) - timersRef.current.clear() - }, []) - - const pauseAll = useCallback(() => { - setIsPaused(true) - setExitingIds(new Set()) - for (const timer of timersRef.current.values()) clearTimeout(timer) - timersRef.current.clear() - }, []) - - const handleAction = useCallback( - (id: string) => { - const t = toasts.find((toast) => toast.id === id) - if (t?.action) { - t.action.onClick() - dismissToast(id) - } - }, - [toasts, dismissToast] - ) - - useEffect(() => { - if (toasts.length === 0) { - if (isPaused) setIsPaused(false) - return - } - if (isPaused) return - - const timers = timersRef.current - - for (const t of toasts) { - if (t.duration <= 0 || timers.has(t.id)) continue - - timers.set( - t.id, - setTimeout(() => { - timers.delete(t.id) - dismissToast(t.id) - }, t.duration) - ) - } - - for (const [id, timer] of timers) { - if (!toasts.some((t) => t.id === id)) { - clearTimeout(timer) - timers.delete(id) - } - } - }, [toasts, isPaused, dismissToast]) - - useEffect(() => { - const timers = timersRef.current - return () => { - for (const timer of timers.values()) clearTimeout(timer) - } + setToasts((prev) => prev.filter((t) => t.id !== id)) }, []) const toastFn = useRef(createToastFn(addToast)) @@ -344,44 +199,24 @@ export function ToastProvider({ children }: { children?: ReactNode }) { }, [addToast]) const ctx = useMemo( - () => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }), - [dismissToast, dismissAll] + () => ({ toast: toastFn.current, dismiss: dismissToast }), + [dismissToast] ) - const visibleToasts = toasts.slice(0, MAX_VISIBLE) - return ( {children} {mounted && - visibleToasts.length > 0 && createPortal( - <> - -
- {[...visibleToasts].reverse().map((t, index, stacked) => { - const depth = stacked.length - index - 1 - const showCountdown = !isPaused && t.duration > 0 - - return ( - - ) - })} -
- , +
+ {toasts.map((t) => ( + + ))} +
, document.body )}
diff --git a/apps/sim/lib/webhooks/pending-verification.ts b/apps/sim/lib/webhooks/pending-verification.ts index 5f851e4104..4d77d35bd2 100644 --- a/apps/sim/lib/webhooks/pending-verification.ts +++ b/apps/sim/lib/webhooks/pending-verification.ts @@ -44,6 +44,7 @@ const pendingWebhookVerificationRegistrationMatchers: Record< string, PendingWebhookVerificationRegistrationMatcher > = { + ashby: () => true, grain: () => true, generic: (registration) => registration.metadata?.verifyTestEvents === true, } @@ -52,6 +53,7 @@ const pendingWebhookVerificationProbeMatchers: Record< string, PendingWebhookVerificationProbeMatcher > = { + ashby: ({ method, body }) => method === 'POST' && body?.action === 'ping', grain: ({ method, body }) => method === 'GET' || method === 'HEAD' || diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 0aeaef6e3d..4860402669 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -20,6 +20,7 @@ import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' import { handleSlackChallenge, handleWhatsAppVerification, + validateAshbySignature, validateAttioSignature, validateCalcomSignature, validateCirclebackSignature, @@ -555,6 +556,29 @@ export async function verifyProviderAuth( } } + // Ashby webhook signature verification (HMAC-SHA256 via Ashby-Signature header) + if (foundWebhook.provider === 'ashby') { + const secretToken = providerConfig.secretToken as string | undefined + + if (secretToken) { + const signature = request.headers.get('ashby-signature') + + if (!signature) { + logger.warn(`[${requestId}] Ashby webhook missing Ashby-Signature header`) + return new NextResponse('Unauthorized - Missing Ashby signature', { + status: 401, + }) + } + + if (!validateAshbySignature(secretToken, signature, rawBody)) { + logger.warn(`[${requestId}] Ashby webhook signature verification failed`) + return new NextResponse('Unauthorized - Invalid Ashby signature', { + status: 401, + }) + } + } + } + // Provider-specific verification (utils may return a response for some providers) const providerVerification = verifyProviderWebhook(foundWebhook, request, requestId) if (providerVerification) { diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 4efbeb35e3..e34f453874 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -2060,7 +2060,11 @@ export async function createExternalWebhookSubscription( if (provider === 'ashby') { const result = await createAshbyWebhookSubscription(webhookData, requestId) if (result) { - updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } + updatedProviderConfig = { + ...updatedProviderConfig, + externalId: result.id, + secretToken: result.secretToken, + } externalSubscriptionCreated = true } } else if (provider === 'airtable') { @@ -2175,7 +2179,7 @@ export async function cleanupExternalWebhook( export async function createAshbyWebhookSubscription( webhookData: any, requestId: string -): Promise<{ id: string } | undefined> { +): Promise<{ id: string; secretToken: string } | undefined> { try { const { path, providerConfig } = webhookData const { apiKey, triggerId } = providerConfig || {} @@ -2213,9 +2217,12 @@ export async function createAshbyWebhookSubscription( webhookId: webhookData.id, }) + const secretToken = crypto.randomUUID() + const requestBody: Record = { requestUrl: notificationUrl, webhookType, + secretToken, } const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', { @@ -2255,7 +2262,7 @@ export async function createAshbyWebhookSubscription( ashbyLogger.info( `[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}` ) - return { id: externalId } + return { id: externalId, secretToken } } catch (error: any) { ashbyLogger.error( `[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`, diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index d202a1b026..9f81f923c0 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1614,6 +1614,38 @@ export function validateFirefliesSignature( } } +/** + * Validates an Ashby webhook signature using HMAC-SHA256. + * Ashby signs payloads with the secretToken and sends the digest in the Ashby-Signature header. + * @param secretToken - The secret token configured when creating the webhook + * @param signature - Ashby-Signature header value (format: 'sha256=') + * @param body - Raw request body string + * @returns Whether the signature is valid + */ +export function validateAshbySignature( + secretToken: string, + signature: string, + body: string +): boolean { + try { + if (!secretToken || !signature || !body) { + return false + } + + if (!signature.startsWith('sha256=')) { + return false + } + + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') + + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Ashby signature:', error) + return false + } +} + /** * Validates a GitHub webhook request signature using HMAC SHA-256 or SHA-1 * @param secret - GitHub webhook secret (plain text) diff --git a/apps/sim/triggers/ashby/application_submit.ts b/apps/sim/triggers/ashby/application_submit.ts index 1c5500cbd3..e000536a88 100644 --- a/apps/sim/triggers/ashby/application_submit.ts +++ b/apps/sim/triggers/ashby/application_submit.ts @@ -1,11 +1,5 @@ import { AshbyIcon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - ashbySetupInstructions, - ashbyTriggerOptions, - buildApplicationSubmitOutputs, - buildAshbyExtraFields, -} from '@/triggers/ashby/utils' +import { buildApplicationSubmitOutputs, buildAshbySubBlocks } from '@/triggers/ashby/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -22,12 +16,10 @@ export const ashbyApplicationSubmitTrigger: TriggerConfig = { version: '1.0.0', icon: AshbyIcon, - subBlocks: buildTriggerSubBlocks({ + subBlocks: buildAshbySubBlocks({ triggerId: 'ashby_application_submit', - triggerOptions: ashbyTriggerOptions, + eventType: 'Application Submitted', includeDropdown: true, - setupInstructions: ashbySetupInstructions('Application Submitted'), - extraFields: buildAshbyExtraFields('ashby_application_submit'), }), outputs: buildApplicationSubmitOutputs(), diff --git a/apps/sim/triggers/ashby/candidate_delete.ts b/apps/sim/triggers/ashby/candidate_delete.ts index e70d26971b..39d33966fe 100644 --- a/apps/sim/triggers/ashby/candidate_delete.ts +++ b/apps/sim/triggers/ashby/candidate_delete.ts @@ -1,11 +1,5 @@ import { AshbyIcon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - ashbySetupInstructions, - ashbyTriggerOptions, - buildAshbyExtraFields, - buildCandidateDeleteOutputs, -} from '@/triggers/ashby/utils' +import { buildAshbySubBlocks, buildCandidateDeleteOutputs } from '@/triggers/ashby/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -21,11 +15,9 @@ export const ashbyCandidateDeleteTrigger: TriggerConfig = { version: '1.0.0', icon: AshbyIcon, - subBlocks: buildTriggerSubBlocks({ + subBlocks: buildAshbySubBlocks({ triggerId: 'ashby_candidate_delete', - triggerOptions: ashbyTriggerOptions, - setupInstructions: ashbySetupInstructions('Candidate Deleted'), - extraFields: buildAshbyExtraFields('ashby_candidate_delete'), + eventType: 'Candidate Deleted', }), outputs: buildCandidateDeleteOutputs(), diff --git a/apps/sim/triggers/ashby/candidate_hire.ts b/apps/sim/triggers/ashby/candidate_hire.ts index 529b15e7f2..3b6a2becc0 100644 --- a/apps/sim/triggers/ashby/candidate_hire.ts +++ b/apps/sim/triggers/ashby/candidate_hire.ts @@ -1,11 +1,5 @@ import { AshbyIcon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - ashbySetupInstructions, - ashbyTriggerOptions, - buildAshbyExtraFields, - buildCandidateHireOutputs, -} from '@/triggers/ashby/utils' +import { buildAshbySubBlocks, buildCandidateHireOutputs } from '@/triggers/ashby/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -22,11 +16,9 @@ export const ashbyCandidateHireTrigger: TriggerConfig = { version: '1.0.0', icon: AshbyIcon, - subBlocks: buildTriggerSubBlocks({ + subBlocks: buildAshbySubBlocks({ triggerId: 'ashby_candidate_hire', - triggerOptions: ashbyTriggerOptions, - setupInstructions: ashbySetupInstructions('Candidate Hired'), - extraFields: buildAshbyExtraFields('ashby_candidate_hire'), + eventType: 'Candidate Hired', }), outputs: buildCandidateHireOutputs(), diff --git a/apps/sim/triggers/ashby/candidate_stage_change.ts b/apps/sim/triggers/ashby/candidate_stage_change.ts index a1a43a6302..38375b2946 100644 --- a/apps/sim/triggers/ashby/candidate_stage_change.ts +++ b/apps/sim/triggers/ashby/candidate_stage_change.ts @@ -1,11 +1,5 @@ import { AshbyIcon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - ashbySetupInstructions, - ashbyTriggerOptions, - buildAshbyExtraFields, - buildCandidateStageChangeOutputs, -} from '@/triggers/ashby/utils' +import { buildAshbySubBlocks, buildCandidateStageChangeOutputs } from '@/triggers/ashby/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -22,11 +16,9 @@ export const ashbyCandidateStageChangeTrigger: TriggerConfig = { version: '1.0.0', icon: AshbyIcon, - subBlocks: buildTriggerSubBlocks({ + subBlocks: buildAshbySubBlocks({ triggerId: 'ashby_candidate_stage_change', - triggerOptions: ashbyTriggerOptions, - setupInstructions: ashbySetupInstructions('Candidate Stage Change'), - extraFields: buildAshbyExtraFields('ashby_candidate_stage_change'), + eventType: 'Candidate Stage Change', }), outputs: buildCandidateStageChangeOutputs(), diff --git a/apps/sim/triggers/ashby/job_create.ts b/apps/sim/triggers/ashby/job_create.ts index 88d60e13c2..05fcf1d1fd 100644 --- a/apps/sim/triggers/ashby/job_create.ts +++ b/apps/sim/triggers/ashby/job_create.ts @@ -1,11 +1,5 @@ import { AshbyIcon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - ashbySetupInstructions, - ashbyTriggerOptions, - buildAshbyExtraFields, - buildJobCreateOutputs, -} from '@/triggers/ashby/utils' +import { buildAshbySubBlocks, buildJobCreateOutputs } from '@/triggers/ashby/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -21,11 +15,9 @@ export const ashbyJobCreateTrigger: TriggerConfig = { version: '1.0.0', icon: AshbyIcon, - subBlocks: buildTriggerSubBlocks({ + subBlocks: buildAshbySubBlocks({ triggerId: 'ashby_job_create', - triggerOptions: ashbyTriggerOptions, - setupInstructions: ashbySetupInstructions('Job Created'), - extraFields: buildAshbyExtraFields('ashby_job_create'), + eventType: 'Job Created', }), outputs: buildJobCreateOutputs(), diff --git a/apps/sim/triggers/ashby/offer_create.ts b/apps/sim/triggers/ashby/offer_create.ts index 3b952b65b7..eef678d41a 100644 --- a/apps/sim/triggers/ashby/offer_create.ts +++ b/apps/sim/triggers/ashby/offer_create.ts @@ -1,11 +1,5 @@ import { AshbyIcon } from '@/components/icons' -import { buildTriggerSubBlocks } from '@/triggers' -import { - ashbySetupInstructions, - ashbyTriggerOptions, - buildAshbyExtraFields, - buildOfferCreateOutputs, -} from '@/triggers/ashby/utils' +import { buildAshbySubBlocks, buildOfferCreateOutputs } from '@/triggers/ashby/utils' import type { TriggerConfig } from '@/triggers/types' /** @@ -21,11 +15,9 @@ export const ashbyOfferCreateTrigger: TriggerConfig = { version: '1.0.0', icon: AshbyIcon, - subBlocks: buildTriggerSubBlocks({ + subBlocks: buildAshbySubBlocks({ triggerId: 'ashby_offer_create', - triggerOptions: ashbyTriggerOptions, - setupInstructions: ashbySetupInstructions('Offer Created'), - extraFields: buildAshbyExtraFields('ashby_offer_create'), + eventType: 'Offer Created', }), outputs: buildOfferCreateOutputs(), diff --git a/apps/sim/triggers/ashby/utils.ts b/apps/sim/triggers/ashby/utils.ts index ff25fcd5f8..d30fa597df 100644 --- a/apps/sim/triggers/ashby/utils.ts +++ b/apps/sim/triggers/ashby/utils.ts @@ -19,10 +19,9 @@ export const ashbyTriggerOptions = [ */ export function ashbySetupInstructions(eventType: string): string { const instructions = [ - 'Enter your Ashby API Key above.', - 'You can find your API key in Ashby at Settings > API Keys. The key must have the apiKeysWrite permission.', - `Click "Save Configuration" to automatically create the webhook in Ashby for ${eventType} events.`, - 'The webhook will be automatically deleted when you remove this trigger.', + 'Enter your Ashby API Key above. You can find your API key in Ashby at Settings > API Keys.', + `The webhook for ${eventType} events will be automatically created in Ashby when you save the trigger.`, + 'The webhook will be automatically deleted if you remove this trigger.', ] return instructions @@ -34,24 +33,54 @@ export function ashbySetupInstructions(eventType: string): string { } /** - * Ashby-specific extra fields for triggers. - * Includes API key (required for automatic webhook creation). + * Builds the complete subBlocks array for an Ashby trigger. + * Ashby webhooks are managed via API, so no webhook URL is displayed. + * + * Structure: [dropdown?] -> apiKey -> instructions */ -export function buildAshbyExtraFields(triggerId: string): SubBlockConfig[] { - return [ - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your Ashby API key', - description: 'Required to create the webhook in Ashby. Must have apiKeysWrite permission.', - password: true, - required: true, - paramVisibility: 'user-only', +export function buildAshbySubBlocks(options: { + triggerId: string + eventType: string + includeDropdown?: boolean +}): SubBlockConfig[] { + const { triggerId, eventType, includeDropdown = false } = options + const blocks: SubBlockConfig[] = [] + + if (includeDropdown) { + blocks.push({ + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', mode: 'trigger', - condition: { field: 'selectedTriggerId', value: triggerId }, - }, - ] + options: ashbyTriggerOptions, + value: () => triggerId, + required: true, + }) + } + + blocks.push({ + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Ashby API key', + password: true, + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }) + + blocks.push({ + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: ashbySetupInstructions(eventType), + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }) + + return blocks } /**