mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(ashby): add secretToken to webhook creation and fix trigger UX (#3580)
* fix(ashby): add secretToken to webhook creation and fix trigger UX * fix(toast): restore useMemo for context value to prevent unnecessary re-renders * fix(notifications): track exit animation timeout so pauseAll can cancel it * fix(notifications): use isPausedRef to guard exit timeout instead of synthetic timer keys * fix(notifications): clear pending timers when notification stack empties
This commit is contained in:
@@ -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%,
|
||||
|
||||
@@ -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 (
|
||||
<ToastProvider>
|
||||
<>
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
@@ -26,6 +25,6 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
</ToastProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+239
-70
@@ -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<NotificationAction['type'], string> = {
|
||||
copilot: 'Fix in Copilot',
|
||||
@@ -19,99 +28,120 @@ const ACTION_LABELS: Record<NotificationAction['type'], string> = {
|
||||
'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 (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={onPause}
|
||||
aria-label='Keep notifications visible'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
|
||||
>
|
||||
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
|
||||
<circle
|
||||
cx='8'
|
||||
cy='8'
|
||||
r={RING_RADIUS}
|
||||
stroke='var(--text-icon)'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={RING_CIRCUMFERENCE}
|
||||
style={{
|
||||
animation: `notification-countdown ${AUTO_DISMISS_MS}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Keep visible</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>())
|
||||
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<Set<string>>(new Set())
|
||||
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
|
||||
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<string>()
|
||||
|
||||
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 (
|
||||
<div ref={preventZoomRef} className='absolute right-[16px] bottom-[16px] z-30 grid'>
|
||||
{[...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 (
|
||||
<div
|
||||
key={notification.id}
|
||||
style={
|
||||
{
|
||||
'--stack-offset': `${xOffset}px`,
|
||||
animation: exitingIds.has(notification.id)
|
||||
? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
|
||||
: 'notification-enter 200ms ease-out forwards',
|
||||
gridArea: '1 / 1',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
>
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
|
||||
{notification.level === 'error' && (
|
||||
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-start gap-[2px]'>
|
||||
{showCountdown && <CountdownRing onPause={pauseAll} />}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
aria-label='Dismiss notification'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{hasAction && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => executeAction(notification.id, notification.action!)}
|
||||
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
{ACTION_LABELS[notification.action!.type] ?? 'Take action'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<ToastContextValue | null>(null)
|
||||
@@ -113,133 +90,81 @@ export function useToast() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: () => void }) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={onPause}
|
||||
aria-label='Keep visible'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
|
||||
>
|
||||
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
|
||||
<circle
|
||||
cx='8'
|
||||
cy='8'
|
||||
r={RING_RADIUS}
|
||||
stroke='var(--text-icon)'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={RING_CIRCUMFERENCE}
|
||||
style={{
|
||||
animation: `toast-countdown ${durationMs}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Keep visible</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
const VARIANT_STYLES: Record<ToastVariant, string> = {
|
||||
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<ReturnType<typeof setTimeout>>(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 (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--stack-offset': `${xOffset}px`,
|
||||
animation: isExiting
|
||||
? `toast-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
|
||||
: 'toast-enter 200ms ease-out forwards',
|
||||
gridArea: '1 / 1',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
className={cn(
|
||||
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
|
||||
VARIANT_STYLES[t.variant],
|
||||
exiting
|
||||
? 'animate-[toast-exit_200ms_ease-in_forwards]'
|
||||
: 'animate-[toast-enter_200ms_ease-out_forwards]'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
{data.icon && (
|
||||
<span className='flex h-[16px] shrink-0 items-center text-[var(--text-icon)]'>
|
||||
{data.icon}
|
||||
</span>
|
||||
)}
|
||||
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
|
||||
{data.variant === 'error' && (
|
||||
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{data.message}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-start gap-[2px]'>
|
||||
{showCountdown && (
|
||||
<CountdownRing durationMs={data.duration} onPause={onPauseCountdown} />
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onDismiss(data.id)}
|
||||
aria-label='Dismiss'
|
||||
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{data.action && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={() => onAction(data.id)}
|
||||
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
|
||||
>
|
||||
{data.action.label}
|
||||
</Button>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='font-medium text-[13px] leading-[18px]'>{t.message}</p>
|
||||
{t.description && (
|
||||
<p className='mt-[2px] text-[12px] leading-[16px] opacity-80'>{t.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{t.action && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
t.action!.onClick()
|
||||
dismiss()
|
||||
}}
|
||||
className='shrink-0 font-medium text-[13px] underline underline-offset-2 opacity-90 hover:opacity-100'
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
onClick={dismiss}
|
||||
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* <ToastProvider />
|
||||
* ```
|
||||
*/
|
||||
export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastData[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
|
||||
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
|
||||
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<ToastFn>(createToastFn(addToast))
|
||||
@@ -344,44 +199,24 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
}, [addToast])
|
||||
|
||||
const ctx = useMemo<ToastContextValue>(
|
||||
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }),
|
||||
[dismissToast, dismissAll]
|
||||
() => ({ toast: toastFn.current, dismiss: dismissToast }),
|
||||
[dismissToast]
|
||||
)
|
||||
|
||||
const visibleToasts = toasts.slice(0, MAX_VISIBLE)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
{children}
|
||||
{mounted &&
|
||||
visibleToasts.length > 0 &&
|
||||
createPortal(
|
||||
<>
|
||||
<style>{TOAST_KEYFRAMES}</style>
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-label='Toasts'
|
||||
className='fixed right-[16px] bottom-[16px] z-[10000400] grid'
|
||||
>
|
||||
{[...visibleToasts].reverse().map((t, index, stacked) => {
|
||||
const depth = stacked.length - index - 1
|
||||
const showCountdown = !isPaused && t.duration > 0
|
||||
|
||||
return (
|
||||
<ToastItem
|
||||
key={t.id}
|
||||
data={t}
|
||||
depth={depth}
|
||||
isExiting={exitingIds.has(t.id)}
|
||||
showCountdown={showCountdown}
|
||||
onDismiss={dismissToast}
|
||||
onPauseCountdown={pauseAll}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-label='Notifications'
|
||||
className='pointer-events-none fixed right-[16px] bottom-[16px] z-[10000400] flex flex-col-reverse items-end gap-[8px]'
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={dismissToast} />
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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}.`,
|
||||
|
||||
@@ -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=<hex>')
|
||||
* @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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 <strong>Settings > API Keys</strong>. The key must have the <strong>apiKeysWrite</strong> permission.',
|
||||
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Ashby for <strong>${eventType}</strong> 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 <strong>Settings > API Keys</strong>.',
|
||||
`The webhook for <strong>${eventType}</strong> 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user