chore: add and improve documentation

This commit is contained in:
Abhishek Kumar 2025-12-23 12:59:49 +05:30
parent 96f8aaf325
commit 8b820c6d8a
23 changed files with 395 additions and 54 deletions

View file

@ -1,4 +1,4 @@
import { Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react';
import { ExternalLink, Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
@ -125,7 +125,18 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
>
<div className="p-4 h-full overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold">Add New Node</h2>
<div className="flex flex-col gap-1">
<h2 className="text-lg font-semibold">Add New Node</h2>
<a
href="https://docs.dograh.com/voice-agent/introduction"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 transition-colors"
>
<ExternalLink className="w-3 h-3" />
View Nodes Documentation
</a>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>

View file

@ -1,5 +1,5 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { AlertCircle, Check, Circle, Copy, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
import { AlertCircle, Circle, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
@ -19,6 +19,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { JsonEditor, validateJson } from "@/components/ui/json-editor";
import { Label } from "@/components/ui/label";
import {
Select,
@ -29,7 +30,6 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
import { NodeContent } from "./common/NodeContent";
@ -93,13 +93,25 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
}
}, [getAccessToken]);
// Validation state - only shown on save attempt
const [jsonError, setJsonError] = useState<string | null>(null);
const [endpointError, setEndpointError] = useState<string | null>(null);
const handleSave = async () => {
let parsedPayload = {};
try {
parsedPayload = JSON.parse(payloadTemplate);
} catch {
// Keep empty object if invalid JSON
// Validate endpoint URL
if (!endpointUrl.trim()) {
setEndpointError('Endpoint URL is required');
return;
}
setEndpointError(null);
// Validate JSON payload
const validation = validateJson(payloadTemplate);
if (!validation.valid) {
setJsonError(validation.error || 'Invalid JSON. Please fix the payload template before saving.');
return;
}
setJsonError(null);
handleSaveNodeData({
...data,
@ -109,7 +121,7 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
endpoint_url: endpointUrl,
credential_uuid: credentialUuid || undefined,
custom_headers: customHeaders.filter((h) => h.key && h.value),
payload_template: parsedPayload,
payload_template: validation.parsed as Record<string, unknown>,
});
setOpen(false);
setTimeout(async () => {
@ -128,6 +140,9 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
setPayloadTemplate(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
// Clear any previous errors
setJsonError(null);
setEndpointError(null);
// Fetch credentials when dialog opens
fetchCredentials();
}
@ -204,6 +219,7 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
nodeData={data}
title="Edit Webhook"
onSave={handleSave}
error={endpointError || jsonError}
>
{open && (
<WebhookNodeEditForm
@ -273,8 +289,6 @@ const WebhookNodeEditForm = ({
payloadTemplate,
setPayloadTemplate,
}: WebhookNodeEditFormProps) => {
const [copied, setCopied] = useState(false);
// Add Credential Dialog state
const [isAddCredentialOpen, setIsAddCredentialOpen] = useState(false);
const [newCredName, setNewCredName] = useState("");
@ -365,12 +379,6 @@ const WebhookNodeEditForm = ({
}
};
const handleCopyPayload = async () => {
await navigator.clipboard.writeText(payloadTemplate);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const addHeader = () => {
setCustomHeaders([...customHeaders, { key: "", value: "" }]);
};
@ -387,14 +395,11 @@ const WebhookNodeEditForm = ({
const availableVariables = [
{ name: "workflow_run_id", description: "Unique ID of the workflow run" },
{ name: "workflow_run_name", description: "Name of the workflow run" },
{ name: "workflow_id", description: "ID of the workflow" },
{ name: "workflow_name", description: "Name of the workflow" },
{ name: "initial_context.*", description: "Initial context variables" },
{ name: "gathered_context.*", description: "Extracted variables" },
{ name: "cost_info.call_duration_seconds", description: "Call duration" },
{ name: "completed_at", description: "Completion timestamp" },
{ name: "disposition_code", description: "Final disposition code" },
{ name: "recording_url", description: "Call recording URL" },
{ name: "transcript_url", description: "Transcript URL" },
];
@ -643,32 +648,14 @@ const WebhookNodeEditForm = ({
</TabsContent>
<TabsContent value="payload" className="space-y-4 mt-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Payload Template (JSON)</Label>
<Button
variant="outline"
size="sm"
onClick={handleCopyPayload}
>
{copied ? (
<Check className="h-4 w-4 mr-1" />
) : (
<Copy className="h-4 w-4 mr-1" />
)}
Copy
</Button>
</div>
<Label className="text-xs text-muted-foreground">
Define the JSON payload. Use {"{{variable}}"} syntax for dynamic values.
</Label>
<Textarea
value={payloadTemplate}
onChange={(e) => setPayloadTemplate(e.target.value)}
className="min-h-[200px] font-mono text-sm"
placeholder='{"call_id": "{{workflow_run_id}}"}'
/>
</div>
<JsonEditor
value={payloadTemplate}
onChange={setPayloadTemplate}
label="Payload Template (JSON)"
description='Define the JSON payload. Use "{{variable}}" syntax for dynamic values (must be quoted strings).'
placeholder='{"call_id": "{{workflow_run_id}}", "name": "{{initial_context.name}}"}'
minHeight="200px"
/>
<div className="border rounded-md p-3 bg-muted/20">
<Label className="text-sm font-medium">Available Variables</Label>

View file

@ -12,6 +12,7 @@ interface NodeEditDialogProps {
title: string;
children: ReactNode;
onSave?: () => void;
error?: string | null;
}
export const NodeEditDialog = ({
@ -20,7 +21,8 @@ export const NodeEditDialog = ({
nodeData,
title,
children,
onSave
onSave,
error
}: NodeEditDialogProps) => {
const handleClose = () => onOpenChange(false);
@ -51,6 +53,12 @@ export const NodeEditDialog = ({
<div className="grid gap-4 py-4">
{children}
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 border border-red-200">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleClose}>Cancel</Button>

View file

@ -0,0 +1,163 @@
import { AlertCircle, Check, Copy } from "lucide-react";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
label?: string;
description?: string;
error?: string | null;
minHeight?: string;
showCopyButton?: boolean;
className?: string;
}
interface JsonValidationResult {
valid: boolean;
parsed: Record<string, unknown> | unknown[];
error?: string;
}
/**
* Validates JSON and provides helpful error messages for common mistakes
*/
export function validateJson(jsonString: string): JsonValidationResult {
const trimmed = jsonString.trim();
// Empty or default empty object is valid
if (!trimmed || trimmed === '{}' || trimmed === '[]') {
return { valid: true, parsed: trimmed === '[]' ? [] : {} };
}
try {
const parsed = JSON.parse(trimmed);
return { valid: true, parsed };
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON';
// Detect common mistakes and provide helpful messages
const helpfulError = getHelpfulJsonError(trimmed, errorMessage);
return { valid: false, parsed: {}, error: helpfulError };
}
}
/**
* Analyzes JSON string and error to provide more helpful error messages
*/
function getHelpfulJsonError(jsonString: string, originalError: string): string {
// Check for unquoted template variables like {{variable}} instead of "{{variable}}"
const unquotedTemplateVar = /:\s*\{\{[^}]+\}\}/.test(jsonString);
if (unquotedTemplateVar) {
return 'Template variables must be quoted strings. Use "{{variable}}" instead of {{variable}}';
}
// Check for trailing comma before } or ]
const trailingComma = /,\s*[}\]]/.test(jsonString);
if (trailingComma) {
return 'Trailing comma detected. Remove the comma before the closing bracket.';
}
// Check for missing comma between properties
const missingComma = /"\s*\n\s*"/.test(jsonString) || /}\s*\n\s*"/.test(jsonString);
if (missingComma) {
return 'Missing comma between properties. Add a comma after each value.';
}
// Check for single quotes instead of double quotes
const singleQuotes = /'[^']*'\s*:/.test(jsonString) || /:\s*'[^']*'/.test(jsonString);
if (singleQuotes) {
return 'JSON requires double quotes. Use "key" instead of \'key\'.';
}
// Check for unquoted string values
const unquotedValue = /:\s*[a-zA-Z][a-zA-Z0-9_]*\s*[,}\]]/.test(jsonString);
if (unquotedValue && !jsonString.includes('true') && !jsonString.includes('false') && !jsonString.includes('null')) {
return 'String values must be quoted. Use "value" instead of value.';
}
// Check for unquoted keys
const unquotedKey = /{\s*[a-zA-Z][a-zA-Z0-9_]*\s*:/.test(jsonString) || /,\s*[a-zA-Z][a-zA-Z0-9_]*\s*:/.test(jsonString);
if (unquotedKey) {
return 'Property names must be quoted. Use "key" instead of key.';
}
// Extract position info from error if available
const positionMatch = originalError.match(/position (\d+)/i);
if (positionMatch) {
const position = parseInt(positionMatch[1], 10);
const lines = jsonString.substring(0, position).split('\n');
const line = lines.length;
const column = lines[lines.length - 1].length + 1;
return `Invalid JSON at line ${line}, column ${column}. Check for missing quotes, commas, or brackets.`;
}
return 'Invalid JSON syntax. Check for missing quotes, commas, or brackets.';
}
export function JsonEditor({
value,
onChange,
placeholder = '{}',
label,
description,
error,
minHeight = "200px",
showCopyButton = true,
className = "",
}: JsonEditorProps) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [value]);
return (
<div className={`grid gap-2 ${className}`}>
{(label || showCopyButton) && (
<div className="flex items-center justify-between">
{label && <Label>{label}</Label>}
{showCopyButton && (
<Button
variant="outline"
size="sm"
onClick={handleCopy}
type="button"
>
{copied ? (
<Check className="h-4 w-4 mr-1" />
) : (
<Copy className="h-4 w-4 mr-1" />
)}
Copy
</Button>
)}
</div>
)}
{description && (
<Label className="text-xs text-muted-foreground">
{description}
</Label>
)}
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className={`font-mono text-sm`}
style={{ minHeight }}
placeholder={placeholder}
/>
{error && (
<div className="flex items-start gap-2 p-2 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
</div>
);
}