mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
chore: add and improve documentation
This commit is contained in:
parent
96f8aaf325
commit
8b820c6d8a
23 changed files with 395 additions and 54 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
163
ui/src/components/ui/json-editor.tsx
Normal file
163
ui/src/components/ui/json-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue