mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +02:00
feat: enable workflows to be embedded in websites as a script tag (#47)
* feat: add deployment configuration options * Simplify EmbedDialog * Add options for inline vs floating embedding of agent
This commit is contained in:
parent
5e4aef346d
commit
99a768f291
40 changed files with 3551 additions and 645 deletions
|
|
@ -7,7 +7,7 @@ import {
|
|||
Panel,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { BrushCleaning, Maximize2, Minus, Plus, Settings, Variable } from 'lucide-react';
|
||||
import { BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
|
|
@ -20,6 +20,7 @@ import AddNodePanel from "../../../components/flow/AddNodePanel";
|
|||
import CustomEdge from "../../../components/flow/edges/CustomEdge";
|
||||
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
|
||||
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
|
||||
import { EmbedDialog } from './components/EmbedDialog';
|
||||
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
|
||||
import WorkflowHeader from "./components/WorkflowHeader";
|
||||
import { WorkflowTabs } from './components/WorkflowTabs';
|
||||
|
|
@ -76,6 +77,7 @@ interface RenderWorkflowProps {
|
|||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: RenderWorkflowProps) {
|
||||
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
|
||||
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
rfInstance,
|
||||
|
|
@ -218,6 +220,22 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
<p>Template Context Variables</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsEmbedDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Deploy Workflow</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Panel>
|
||||
|
|
@ -317,6 +335,14 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
templateContextVariables={templateContextVariables}
|
||||
onSave={saveTemplateContextVariables}
|
||||
/>
|
||||
|
||||
<EmbedDialog
|
||||
open={isEmbedDialogOpen}
|
||||
onOpenChange={setIsEmbedDialogOpen}
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowName}
|
||||
getAccessToken={getAccessToken}
|
||||
/>
|
||||
</WorkflowLayout>
|
||||
</WorkflowProvider>
|
||||
);
|
||||
|
|
|
|||
507
ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx
Normal file
507
ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
import { Check, Copy, Loader2, Plus, Rocket, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { client } from "@/client/client.gen";
|
||||
import {
|
||||
createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost,
|
||||
deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete,
|
||||
getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet,
|
||||
} from "@/client/sdk.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface EmbedDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflowId: number;
|
||||
workflowName: string;
|
||||
getAccessToken: () => Promise<string>;
|
||||
}
|
||||
|
||||
interface EmbedToken {
|
||||
id: number;
|
||||
token: string;
|
||||
allowed_domains: string[] | null;
|
||||
settings: Record<string, unknown> | null;
|
||||
is_active: boolean;
|
||||
usage_count: number;
|
||||
usage_limit: number | null;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
embed_script: string;
|
||||
}
|
||||
|
||||
export function EmbedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowId,
|
||||
workflowName,
|
||||
getAccessToken,
|
||||
}: EmbedDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [embedToken, setEmbedToken] = useState<EmbedToken | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [newDomain, setNewDomain] = useState("");
|
||||
const [embedMode, setEmbedMode] = useState<"floating" | "inline">("floating");
|
||||
const [position, setPosition] = useState("bottom-right");
|
||||
const [buttonText, setButtonText] = useState("Start Voice Call");
|
||||
const [buttonColor, setButtonColor] = useState("#3B82F6");
|
||||
|
||||
const loadEmbedToken = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
client.setConfig({
|
||||
baseUrl: window.location.origin.replace(/:\d+$/, ':8000'),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const response = await getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet({
|
||||
path: { workflow_id: workflowId },
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setEmbedToken(response.data as EmbedToken);
|
||||
setIsEnabled(response.data.is_active);
|
||||
|
||||
// Load settings
|
||||
if (response.data.settings) {
|
||||
const settings = response.data.settings as Record<string, string>;
|
||||
setEmbedMode((settings.embedMode as "floating" | "inline") || "floating");
|
||||
setPosition(settings.position || "bottom-right");
|
||||
setButtonText(settings.buttonText || "Start Voice Call");
|
||||
setButtonColor(settings.buttonColor || "#3B82F6");
|
||||
}
|
||||
|
||||
// Load domains
|
||||
if (response.data.allowed_domains) {
|
||||
setDomains(response.data.allowed_domains);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load embed token:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadEmbedToken();
|
||||
}
|
||||
}, [open, loadEmbedToken]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
client.setConfig({
|
||||
baseUrl: window.location.origin.replace(/:\d+$/, ':8000'),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!isEnabled && embedToken) {
|
||||
// Deactivate token
|
||||
await deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete({
|
||||
path: { workflow_id: workflowId },
|
||||
});
|
||||
setEmbedToken(null);
|
||||
} else if (isEnabled) {
|
||||
// Create or update token
|
||||
const response = await createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost({
|
||||
path: { workflow_id: workflowId },
|
||||
body: {
|
||||
allowed_domains: domains.length > 0 ? domains : null,
|
||||
settings: {
|
||||
embedMode,
|
||||
position,
|
||||
buttonText,
|
||||
buttonColor,
|
||||
size: "medium",
|
||||
autoStart: false,
|
||||
containerId: embedMode === "inline" ? "dograh-inline-container" : undefined,
|
||||
},
|
||||
usage_limit: null,
|
||||
expires_in_days: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setEmbedToken(response.data as EmbedToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't close modal after saving - let user copy the embed code
|
||||
} catch (error) {
|
||||
console.error("Failed to save embed token:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const addDomain = () => {
|
||||
if (newDomain.trim() && !domains.includes(newDomain.trim())) {
|
||||
setDomains([...domains, newDomain.trim()]);
|
||||
setNewDomain("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeDomain = (domain: string) => {
|
||||
setDomains(domains.filter(d => d !== domain));
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addDomain();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Rocket className="h-5 w-5" />
|
||||
Deploy Workflow
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Embed "{workflowName}" on any website with a simple script tag
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="embed-enabled">Enable Embedding</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow this workflow to be embedded on external websites
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="embed-enabled"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={setIsEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEnabled && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Allowed Domains */}
|
||||
<div className="space-y-3">
|
||||
<Label>
|
||||
Allowed Domains
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(leave empty to allow all domains)
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{/* Domain Input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="example.com or *.example.com"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={addDomain}
|
||||
disabled={!newDomain.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
{domains.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{domains.map((domain, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-muted/50 rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-mono">{domain}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
onClick={() => removeDomain(domain)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Embed Mode Selection */}
|
||||
<div className="space-y-4">
|
||||
<Label>Embed Mode</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbedMode("floating")}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
embedMode === "floating"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted hover:border-muted-foreground/20"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">Floating Widget</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Shows as a button in corner of the page
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbedMode("inline")}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
embedMode === "inline"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted hover:border-muted-foreground/20"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">Inline Component</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Embeds directly in your page content
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration based on mode */}
|
||||
<div className="space-y-4">
|
||||
<Label>Configuration</Label>
|
||||
|
||||
{embedMode === "floating" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position" className="text-sm">Position</Label>
|
||||
<Select value={position} onValueChange={setPosition}>
|
||||
<SelectTrigger id="position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bottom-right">Bottom Right</SelectItem>
|
||||
<SelectItem value="bottom-left">Bottom Left</SelectItem>
|
||||
<SelectItem value="top-right">Top Right</SelectItem>
|
||||
<SelectItem value="top-left">Top Left</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-color" className="text-sm">Button Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="button-color-picker"
|
||||
type="color"
|
||||
value={buttonColor}
|
||||
onChange={(e) => setButtonColor(e.target.value)}
|
||||
className="w-14 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
id="button-color"
|
||||
value={buttonColor}
|
||||
onChange={(e) => setButtonColor(e.target.value)}
|
||||
placeholder="#3B82F6"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-text" className="text-sm">Button Text</Label>
|
||||
<Input
|
||||
id="button-text"
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder="Start Voice Call"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-muted/50 p-4">
|
||||
<h4 className="font-medium mb-2">Integration Instructions</h4>
|
||||
<ul className="text-sm space-y-2 text-muted-foreground">
|
||||
<li>• Add a div with id="dograh-inline-container" where you want the widget</li>
|
||||
<li>• The widget will render inside this container</li>
|
||||
<li>• You have full control over the container's styling</li>
|
||||
<li>• Call window.DograhWidget.start() to begin the call</li>
|
||||
<li>• Call window.DograhWidget.end() to end the call</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/20 p-4 border border-blue-200 dark:border-blue-800">
|
||||
<h4 className="font-medium mb-2 text-blue-900 dark:text-blue-100">Example React Component</h4>
|
||||
<pre className="text-xs overflow-x-auto">
|
||||
<code className="text-blue-800 dark:text-blue-200">{`export function DograhAgent() {
|
||||
const [isCallActive, setIsCallActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Widget will auto-initialize when script loads
|
||||
window.DograhWidget?.onCallStart(() => {
|
||||
setIsCallActive(true);
|
||||
});
|
||||
window.DograhWidget?.onCallEnd(() => {
|
||||
setIsCallActive(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<h2>Talk to Our Agent</h2>
|
||||
<div id="dograh-inline-container" className="min-h-[400px]">
|
||||
{/* Widget renders here */}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.DograhWidget?.start()}
|
||||
disabled={isCallActive}
|
||||
>
|
||||
Start Call
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview for floating mode only */}
|
||||
{embedMode === "floating" && (
|
||||
<div className="rounded-lg border bg-background p-4 flex items-center justify-center">
|
||||
<button
|
||||
className="px-5 py-2.5 rounded-full font-medium shadow-lg hover:shadow-xl transition-all flex items-center gap-2"
|
||||
style={{
|
||||
backgroundColor: buttonColor,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||
</svg>
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Configurations"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Embed Script (shows after saving) */}
|
||||
{embedToken && embedToken.is_active && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Embed Code</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(embedToken.embed_script)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted/50 rounded-lg p-4 text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
<code>{embedToken.embed_script}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add this script to your website's HTML to enable the voice widget.
|
||||
Configuration changes will apply automatically without re-embedding.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -54,42 +54,41 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
||||
|
||||
// Load disposition codes from workflow configuration
|
||||
useEffect(() => {
|
||||
const loadDispositionCodes = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
const loadDispositionCodes = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
const workflow = response.data;
|
||||
if (workflow?.call_disposition_codes) {
|
||||
// Update the disposition code attribute with actual options
|
||||
const updatedAttributes = configuredAttributes.map(attr => {
|
||||
if (attr.id === 'dispositionCode') {
|
||||
return {
|
||||
...attr,
|
||||
config: {
|
||||
...attr.config,
|
||||
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
|
||||
? Object.keys(workflow.call_disposition_codes || {})
|
||||
: [...DISPOSITION_CODES]
|
||||
}
|
||||
};
|
||||
}
|
||||
return attr;
|
||||
});
|
||||
setConfiguredAttributes(updatedAttributes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load disposition codes:", err);
|
||||
const workflow = response.data;
|
||||
if (workflow?.call_disposition_codes) {
|
||||
// Update the disposition code attribute with actual options
|
||||
setConfiguredAttributes(prev => prev.map(attr => {
|
||||
if (attr.id === 'dispositionCode') {
|
||||
return {
|
||||
...attr,
|
||||
config: {
|
||||
...attr.config,
|
||||
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
|
||||
? Object.keys(workflow.call_disposition_codes || {})
|
||||
: [...DISPOSITION_CODES]
|
||||
}
|
||||
};
|
||||
}
|
||||
return attr;
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
loadDispositionCodes();
|
||||
} catch (err) {
|
||||
console.error("Failed to load disposition codes:", err);
|
||||
}
|
||||
}, [workflowId, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDispositionCodes();
|
||||
}, [loadDispositionCodes]);
|
||||
|
||||
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export const useWorkflowState = ({
|
|||
initialTemplateContextVariables,
|
||||
initialWorkflowConfigurations
|
||||
);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Set up keyboard shortcuts for undo/redo
|
||||
useEffect(() => {
|
||||
|
|
@ -418,7 +418,7 @@ export const useWorkflowState = ({
|
|||
// Validate workflow on mount
|
||||
useEffect(() => {
|
||||
validateWorkflow();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rfInstance,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export default function WorkflowDetailPage() {
|
|||
const stickyTabs = workflow ? <WorkflowTabs workflowId={workflow.id} currentTab={currentTab} /> : null;
|
||||
|
||||
// Memoize user and getAccessToken to prevent unnecessary re-renders
|
||||
const stableUser = useMemo(() => user, [user?.id]);
|
||||
const stableUser = useMemo(() => user, [user]);
|
||||
const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]);
|
||||
|
||||
if (loading) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -234,6 +234,44 @@ export type DuplicateTemplateRequest = {
|
|||
workflow_name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response model for embed configuration
|
||||
*/
|
||||
export type EmbedConfigResponse = {
|
||||
workflow_id: number;
|
||||
settings: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
theme: string;
|
||||
position: string;
|
||||
button_text: string;
|
||||
button_color: string;
|
||||
};
|
||||
|
||||
export type EmbedTokenRequest = {
|
||||
allowed_domains?: Array<string> | null;
|
||||
settings?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
usage_limit?: number | null;
|
||||
expires_in_days?: number | null;
|
||||
};
|
||||
|
||||
export type EmbedTokenResponse = {
|
||||
id: number;
|
||||
token: string;
|
||||
allowed_domains: Array<string> | null;
|
||||
settings: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
is_active: boolean;
|
||||
usage_count: number;
|
||||
usage_limit: number | null;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
embed_script: string;
|
||||
};
|
||||
|
||||
export type FileMetadataResponse = {
|
||||
key: string;
|
||||
metadata: {
|
||||
|
|
@ -261,6 +299,27 @@ export type ImpersonateResponse = {
|
|||
access_token: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request model for initializing an embed session
|
||||
*/
|
||||
export type InitEmbedRequest = {
|
||||
token: string;
|
||||
context_variables?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response model for embed initialization
|
||||
*/
|
||||
export type InitEmbedResponse = {
|
||||
session_token: string;
|
||||
workflow_run_id: number;
|
||||
config: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type InitiateCallRequest = {
|
||||
workflow_id: number;
|
||||
workflow_run_id?: number | null;
|
||||
|
|
@ -2888,6 +2947,220 @@ export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses = {
|
|||
|
||||
export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse = GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses[keyof GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses];
|
||||
|
||||
export type OptionsInitApiV1PublicEmbedInitOptionsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/public/embed/init';
|
||||
};
|
||||
|
||||
export type OptionsInitApiV1PublicEmbedInitOptionsErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type OptionsInitApiV1PublicEmbedInitOptionsResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type InitializeEmbedSessionApiV1PublicEmbedInitPostData = {
|
||||
body: InitEmbedRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/public/embed/init';
|
||||
};
|
||||
|
||||
export type InitializeEmbedSessionApiV1PublicEmbedInitPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type InitializeEmbedSessionApiV1PublicEmbedInitPostError = InitializeEmbedSessionApiV1PublicEmbedInitPostErrors[keyof InitializeEmbedSessionApiV1PublicEmbedInitPostErrors];
|
||||
|
||||
export type InitializeEmbedSessionApiV1PublicEmbedInitPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: InitEmbedResponse;
|
||||
};
|
||||
|
||||
export type InitializeEmbedSessionApiV1PublicEmbedInitPostResponse = InitializeEmbedSessionApiV1PublicEmbedInitPostResponses[keyof InitializeEmbedSessionApiV1PublicEmbedInitPostResponses];
|
||||
|
||||
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetData = {
|
||||
body?: never;
|
||||
path: {
|
||||
token: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/public/embed/config/{token}';
|
||||
};
|
||||
|
||||
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetError = GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors];
|
||||
|
||||
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: EmbedConfigResponse;
|
||||
};
|
||||
|
||||
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse = GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses];
|
||||
|
||||
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = {
|
||||
body?: never;
|
||||
path: {
|
||||
token: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/public/embed/config/{token}';
|
||||
};
|
||||
|
||||
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors];
|
||||
|
||||
export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/workflow/{workflow_id}/embed-token';
|
||||
};
|
||||
|
||||
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError = DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors[keyof DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors];
|
||||
|
||||
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse = DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses[keyof DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses];
|
||||
|
||||
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/workflow/{workflow_id}/embed-token';
|
||||
};
|
||||
|
||||
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError = GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors[keyof GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors];
|
||||
|
||||
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: EmbedTokenResponse | null;
|
||||
};
|
||||
|
||||
export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse = GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses[keyof GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses];
|
||||
|
||||
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData = {
|
||||
body: EmbedTokenRequest;
|
||||
headers?: {
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
workflow_id: number;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/workflow/{workflow_id}/embed-token';
|
||||
};
|
||||
|
||||
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError = CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors[keyof CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors];
|
||||
|
||||
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: EmbedTokenResponse;
|
||||
};
|
||||
|
||||
export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse = CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses[keyof CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses];
|
||||
|
||||
export type HealthApiV1HealthGetData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
66
ui/src/components/ui/tabs.tsx
Normal file
66
ui/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client"
|
||||
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsContent,TabsList, TabsTrigger }
|
||||
|
|
@ -80,8 +80,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (!auth.loading) {
|
||||
fetchPermissions();
|
||||
}
|
||||
// We intentionally depend only on specific auth properties to avoid infinite loops
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [auth.loading, auth.provider, auth.getSelectedTeam, auth.listPermissions]);
|
||||
|
||||
|
||||
|
|
@ -152,8 +150,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// We intentionally depend only on specific auth properties to avoid infinite loops
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [auth.loading, auth.isAuthenticated, auth.getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue