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) && (
+
+
+ {mutations.isUpdatePending ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )}
);
};
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.
+
+
+ )}