mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2025-12-16 22:35:11 -06:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user