Update Expression Page + Slack Webhook Onboarding (#2614)

* better slack webhook onboarding

* lint

* lint again

* lint again

* lint again?

* chore: lint

* fix

* Pr feedback

* lint

* lint

---------

Co-authored-by: mrkaye97 <mrkaye97@gmail.com>
This commit is contained in:
Sid Premkumar
2025-12-08 11:11:16 -05:00
committed by GitHub
parent bede3efe0d
commit 7651cdd58e
4 changed files with 171 additions and 54 deletions

View File

@@ -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;
}

View File

@@ -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.
<div className="space-y-4">
@@ -193,6 +197,27 @@ export const PreconfiguredHMACAuth = ({
className="h-10 pr-10"
/>
</div>
{helpText && (
<div className="text-xs text-muted-foreground pl-1">
<p>
{helpText}
{helpLink && (
<>
{' '}
<a
href={helpLink}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Learn more
</a>
.
</>
)}
</p>
</div>
)}
</div>
</div>
);
@@ -239,7 +264,13 @@ export const AuthSetup = ({
/>
);
case V1WebhookSourceName.SLACK:
return <PreconfiguredHMACAuth register={register} />;
return (
<PreconfiguredHMACAuth
register={register}
helpText="You can find your signing secret in the Basic Information panel of your Slack app settings."
helpLink="https://docs.slack.dev/authentication/verifying-requests-from-slack/#validating-a-request"
/>
);
default:
const exhaustiveCheck: never = sourceName;
throw new Error(`Unhandled source name: ${exhaustiveCheck}`);

View File

@@ -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<V1Webhook> }) => {
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<V1Webhook> }) => {
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 (
<div className="flex flex-row items-center gap-x-2">
<Input
value={isEditing ? value : row.original.eventKeyExpression || ''}
onChange={isEditing ? (e) => 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}
/>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
disabled={!isEditing}
>
<Save className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCancel}
disabled={value === row.original.eventKeyExpression || !isEditing}
>
<X className="size-4" />
</Button>
<div className="relative w-full">
<Input
value={value}
onChange={(e) => {
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}
/>
</div>
{(isEditing || hasChanges) && (
<div className="flex flex-row items-center gap-x-2 animate-in fade-in-0 slide-in-from-right-2 duration-200">
<Button
variant="ghost"
size="icon"
onClick={handleSave}
className={`h-7 w-7 ${
hasChanges && !mutations.isUpdatePending
? 'text-red-500/80 animate-pulse'
: ''
}`}
disabled={!hasChanges || !value.trim() || mutations.isUpdatePending}
>
{mutations.isUpdatePending ? (
<Loader className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCancel}
className="h-7 w-7"
disabled={mutations.isUpdatePending}
>
<X className="size-3" />
</Button>
</div>
)}
</div>
);
};

View File

@@ -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 = () => {
<li>`input` refers to the payload</li>
<li>`headers` refers to the headers</li>
</ul>
{sourceName === V1WebhookSourceName.SLACK && (
<div className="mt-2 p-3 bg-muted border border-border rounded-md">
<p className="text-xs text-muted-foreground">
For Slack webhooks, the event key expression{' '}
<code className="bg-background px-1.5 py-0.5 rounded text-foreground">
input.type
</code>{' '}
works well since Slack interactive payloads don't have a
top-level `id` field.
</p>
</div>
)}
</div>
</div>