diff --git a/frontend/app/src/index.css b/frontend/app/src/index.css index 57071d002..bf6f2ad4f 100644 --- a/frontend/app/src/index.css +++ b/frontend/app/src/index.css @@ -460,3 +460,17 @@ pre.shiki > code > span::before { display: block; } } + +/* Subtle border pulse animation for unsaved changes */ +@keyframes pulse-border { + 0%, 100% { + border-color: rgba(239, 68, 68, 0.5); + } + 50% { + border-color: rgba(239, 68, 68, 0.7); + } +} + +.animate-pulse-border { + animation: pulse-border 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} diff --git a/frontend/app/src/pages/main/v1/webhooks/components/auth-setup.tsx b/frontend/app/src/pages/main/v1/webhooks/components/auth-setup.tsx index 5a63323c9..6e233f34a 100644 --- a/frontend/app/src/pages/main/v1/webhooks/components/auth-setup.tsx +++ b/frontend/app/src/pages/main/v1/webhooks/components/auth-setup.tsx @@ -7,13 +7,13 @@ import { SelectTrigger, SelectValue, } from '@/components/v1/ui/select'; -import { useForm } from 'react-hook-form'; import { V1WebhookAuthType, V1WebhookHMACAlgorithm, V1WebhookHMACEncoding, V1WebhookSourceName, } from '@/lib/api'; +import { useForm } from 'react-hook-form'; import { WebhookFormData } from '../hooks/use-webhooks'; type BaseAuthMethodProps = { @@ -173,9 +173,13 @@ export const PreconfiguredHMACAuth = ({ register, secretLabel = 'Signing Secret', secretPlaceholder = 'super-secret', + helpText, + helpLink, }: BaseAuthMethodProps & { secretLabel?: string; secretPlaceholder?: string; + helpText?: string; + helpLink?: string; }) => ( // Intended to be used for Stripe, Slack, Github, Linear, etc.
@@ -193,6 +197,27 @@ export const PreconfiguredHMACAuth = ({ className="h-10 pr-10" />
+ {helpText && ( +
+

+ {helpText} + {helpLink && ( + <> + {' '} + + Learn more + + . + + )} +

+
+ )} ); @@ -239,7 +264,13 @@ export const AuthSetup = ({ /> ); case V1WebhookSourceName.SLACK: - return ; + return ( + + ); default: const exhaustiveCheck: never = sourceName; throw new Error(`Unhandled source name: ${exhaustiveCheck}`); diff --git a/frontend/app/src/pages/main/v1/webhooks/components/webhook-columns.tsx b/frontend/app/src/pages/main/v1/webhooks/components/webhook-columns.tsx index fcd24c3d6..016fb5dde 100644 --- a/frontend/app/src/pages/main/v1/webhooks/components/webhook-columns.tsx +++ b/frontend/app/src/pages/main/v1/webhooks/components/webhook-columns.tsx @@ -1,20 +1,20 @@ -import { ColumnDef, Row } from '@tanstack/react-table'; -import { V1Webhook } from '@/lib/api'; import { DataTableColumnHeader } from '@/components/v1/molecules/data-table/data-table-column-header'; -import { DotsVerticalIcon } from '@radix-ui/react-icons'; -import { Check, Copy, Loader, Save, Trash2, X } from 'lucide-react'; import { Button } from '@/components/v1/ui/button'; -import { Input } from '@/components/v1/ui/input'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/v1/ui/dropdown-menu'; -import { useCallback, useState } from 'react'; +import { Input } from '@/components/v1/ui/input'; +import { V1Webhook } from '@/lib/api'; +import { DotsVerticalIcon } from '@radix-ui/react-icons'; +import { ColumnDef, Row } from '@tanstack/react-table'; +import { Check, Copy, Loader, Save, Trash2, X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; import { useWebhooks } from '../hooks/use-webhooks'; -import { SourceName } from './source-name'; import { AuthMethod } from './auth-method'; +import { SourceName } from './source-name'; export const WebhookColumn = { name: 'Name', @@ -161,6 +161,17 @@ const EditableExpressionCell = ({ row }: { row: Row }) => { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(row.original.eventKeyExpression || ''); + const hasChanges = + value.trim() !== (row.original.eventKeyExpression || '').trim() && + value.trim() !== ''; + + // Sync value when row data changes (e.g., after successful save) and there are no unsaved changes + useEffect(() => { + if (!isEditing && !hasChanges) { + setValue(row.original.eventKeyExpression || ''); + } + }, [row.original.eventKeyExpression, isEditing, hasChanges]); + const handleSave = useCallback(() => { if (value !== row.original.eventKeyExpression && value.trim()) { mutations.updateWebhook({ @@ -176,36 +187,72 @@ const EditableExpressionCell = ({ row }: { row: Row }) => { setIsEditing(false); }, [row.original.eventKeyExpression, setIsEditing, setValue]); + const handleBlur = useCallback(() => { + // Only auto-save if there are no changes, otherwise keep buttons visible + if (!hasChanges) { + setIsEditing(false); + } + }, [hasChanges]); + return (
- setValue(e.target.value) : undefined} - onClick={!isEditing ? () => setIsEditing(true) : undefined} - className={`bg-muted rounded px-2 py-3 font-mono text-xs w-full h-6 ${ - isEditing - ? 'border-input focus:border-ring focus:ring-1 focus:ring-ring cursor-text' - : 'border-transparent cursor-text hover:bg-muted/80' - }`} - readOnly={!isEditing} - autoFocus={isEditing} - /> - - +
+ { + setValue(e.target.value); + if (!isEditing) { + setIsEditing(true); + } + }} + onClick={!isEditing ? () => setIsEditing(true) : undefined} + onBlur={handleBlur} + onKeyDown={(e) => { + if (e.key === 'Enter' && hasChanges) { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }} + className={`bg-muted rounded px-2 py-3 font-mono text-xs w-full h-6 transition-colors ${ + isEditing || hasChanges + ? 'border-input focus:border-ring focus:ring-1 focus:ring-ring cursor-text' + : 'border-transparent cursor-text hover:bg-muted/80' + }`} + readOnly={!isEditing && !hasChanges} + autoFocus={isEditing} + /> +
+ {(isEditing || hasChanges) && ( +
+ + +
+ )}
); }; diff --git a/frontend/app/src/pages/main/v1/webhooks/index.tsx b/frontend/app/src/pages/main/v1/webhooks/index.tsx index a7d13bd60..65e2107e6 100644 --- a/frontend/app/src/pages/main/v1/webhooks/index.tsx +++ b/frontend/app/src/pages/main/v1/webhooks/index.tsx @@ -1,10 +1,5 @@ -import { columns, WebhookColumn } from './components/webhook-columns'; +import { DocsButton } from '@/components/v1/docs/docs-button'; import { DataTable } from '@/components/v1/molecules/data-table/data-table'; -import { - useWebhooks, - WebhookFormData, - webhookFormSchema, -} from './hooks/use-webhooks'; import { Button } from '@/components/v1/ui/button'; import { Dialog, @@ -16,6 +11,7 @@ import { } from '@/components/v1/ui/dialog'; import { Input } from '@/components/v1/ui/input'; import { Label } from '@/components/v1/ui/label'; +import { Spinner } from '@/components/v1/ui/loading'; import { Select, SelectContent, @@ -23,24 +19,28 @@ import { SelectTrigger, SelectValue, } from '@/components/v1/ui/select'; -import { useCallback, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; import { - V1WebhookSourceName, - V1WebhookAuthType, V1CreateWebhookRequest, + V1WebhookAuthType, V1WebhookHMACAlgorithm, V1WebhookHMACEncoding, + V1WebhookSourceName, } from '@/lib/api'; -import { Webhook, Copy, Check, AlertTriangle, Lightbulb } from 'lucide-react'; -import { Spinner } from '@/components/v1/ui/loading'; -import { SourceName } from './components/source-name'; +import { docsPages } from '@/lib/generated/docs'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { AlertTriangle, Check, Copy, Lightbulb, Webhook } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Link } from 'react-router-dom'; import { AuthMethod } from './components/auth-method'; import { AuthSetup } from './components/auth-setup'; -import { Link } from 'react-router-dom'; -import { DocsButton } from '@/components/v1/docs/docs-button'; -import { docsPages } from '@/lib/generated/docs'; +import { SourceName } from './components/source-name'; +import { columns, WebhookColumn } from './components/webhook-columns'; +import { + useWebhooks, + WebhookFormData, + webhookFormSchema, +} from './hooks/use-webhooks'; export default function Webhooks() { const { data, isLoading, error } = useWebhooks(); @@ -294,6 +294,19 @@ const CreateWebhookModal = () => { const sourceName = watch('sourceName'); const authType = watch('authType'); const webhookName = watch('name'); + const eventKeyExpression = watch('eventKeyExpression'); + + /* Update default event key expression when source changes */ + useEffect(() => { + if (sourceName === V1WebhookSourceName.SLACK && !eventKeyExpression) { + setValue('eventKeyExpression', 'input.type'); + } else if ( + sourceName === V1WebhookSourceName.GENERIC && + !eventKeyExpression + ) { + setValue('eventKeyExpression', 'input.id'); + } + }, [sourceName, eventKeyExpression, setValue]); const copyToClipboard = useCallback(async () => { if (webhookName) { @@ -465,6 +478,18 @@ const CreateWebhookModal = () => {
  • `input` refers to the payload
  • `headers` refers to the headers
  • + {sourceName === V1WebhookSourceName.SLACK && ( +
    +

    + For Slack webhooks, the event key expression{' '} + + input.type + {' '} + works well since Slack interactive payloads don't have a + top-level `id` field. +

    +
    + )}