Skip to content

Commit 99f2acd

Browse files
CopilotCopilot
andcommitted
feat(plugin-workflow): add AutomationBuilder and AutomationRunHistory components
Phase 18 L1: Trigger-Action pipeline configuration UI. - AutomationBuilder: 3-section UI for configuring trigger type, actions pipeline, and automation metadata (name, description, toggle) - AutomationRunHistory: Status-badged list of past automation runs with duration, trigger event, and error display - Register both as 'automation-builder' and 'automation-run-history' in ComponentRegistry - Export all interfaces (TriggerConfig, ActionConfig, AutomationDefinition, AutomationRun) for consumers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ef6fb5e commit 99f2acd

5 files changed

Lines changed: 538 additions & 1 deletion

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React, { useState, useCallback } from 'react';
10+
import {
11+
Card, CardContent, CardHeader, CardTitle,
12+
Button, Input, Label, Badge, Separator, Switch,
13+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
14+
} from '@object-ui/components';
15+
import { Zap, Plus, Trash2, Settings, Clock, Mail, Bell, Globe, FileEdit } from 'lucide-react';
16+
17+
export interface TriggerConfig {
18+
type: 'record_created' | 'record_updated' | 'record_deleted' | 'field_changed' | 'scheduled';
19+
objectName?: string;
20+
fieldName?: string;
21+
schedule?: string;
22+
condition?: string;
23+
}
24+
25+
export interface ActionConfig {
26+
type: 'send_email' | 'update_field' | 'create_record' | 'delete_record' | 'webhook' | 'notification';
27+
params: Record<string, any>;
28+
}
29+
30+
export interface AutomationDefinition {
31+
id: string;
32+
name: string;
33+
description?: string;
34+
enabled: boolean;
35+
trigger: TriggerConfig;
36+
actions: ActionConfig[];
37+
createdAt: string;
38+
lastRunAt?: string;
39+
}
40+
41+
export interface AutomationBuilderProps {
42+
automation?: AutomationDefinition;
43+
objects?: Array<{ name: string; label: string; fields?: Record<string, any> }>;
44+
onSave?: (automation: AutomationDefinition) => void;
45+
onCancel?: () => void;
46+
className?: string;
47+
}
48+
49+
const TRIGGER_LABELS: Record<TriggerConfig['type'], string> = {
50+
record_created: 'Record Created', record_updated: 'Record Updated',
51+
record_deleted: 'Record Deleted', field_changed: 'Field Changed', scheduled: 'Scheduled',
52+
};
53+
54+
const ACTION_LABELS: Record<ActionConfig['type'], string> = {
55+
send_email: 'Send Email', update_field: 'Update Field', create_record: 'Create Record',
56+
delete_record: 'Delete Record', webhook: 'Webhook', notification: 'Notification',
57+
};
58+
59+
const ACTION_ICONS: Record<ActionConfig['type'], React.ReactNode> = {
60+
send_email: <Mail className="h-4 w-4" />, update_field: <FileEdit className="h-4 w-4" />,
61+
create_record: <Plus className="h-4 w-4" />, delete_record: <Trash2 className="h-4 w-4" />,
62+
webhook: <Globe className="h-4 w-4" />, notification: <Bell className="h-4 w-4" />,
63+
};
64+
65+
const defaultAutomation = (): AutomationDefinition => ({
66+
id: `auto-${Date.now()}`, name: '', description: '', enabled: true,
67+
trigger: { type: 'record_created' }, actions: [], createdAt: new Date().toISOString(),
68+
});
69+
70+
/** Helper to render a labelled input for action params */
71+
const ParamInput: React.FC<{
72+
label: string; value: string; placeholder: string;
73+
onChange: (v: string) => void;
74+
}> = ({ label, value, placeholder, onChange }) => (
75+
<div className="space-y-1">
76+
<Label className="text-xs">{label}</Label>
77+
<Input value={value} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={placeholder} />
78+
</div>
79+
);
80+
81+
/**
82+
* AutomationBuilder - Trigger-Action pipeline configuration UI
83+
* Lets users configure automations with trigger selection, action configuration,
84+
* and metadata (name, description, enable/disable).
85+
*/
86+
export const AutomationBuilder: React.FC<AutomationBuilderProps> = ({
87+
automation: initial, objects = [], onSave, onCancel, className,
88+
}) => {
89+
const [automation, setAutomation] = useState<AutomationDefinition>(initial ?? defaultAutomation());
90+
91+
const updateTrigger = useCallback((updates: Partial<TriggerConfig>) => {
92+
setAutomation(prev => ({ ...prev, trigger: { ...prev.trigger, ...updates } }));
93+
}, []);
94+
95+
const addAction = useCallback(() => {
96+
setAutomation(prev => ({ ...prev, actions: [...prev.actions, { type: 'send_email', params: {} }] }));
97+
}, []);
98+
99+
const updateAction = useCallback((index: number, updates: Partial<ActionConfig>) => {
100+
setAutomation(prev => ({ ...prev, actions: prev.actions.map((a, i) => (i === index ? { ...a, ...updates } : a)) }));
101+
}, []);
102+
103+
const removeAction = useCallback((index: number) => {
104+
setAutomation(prev => ({ ...prev, actions: prev.actions.filter((_, i) => i !== index) }));
105+
}, []);
106+
107+
const needsObject = automation.trigger.type !== 'scheduled';
108+
const needsField = automation.trigger.type === 'field_changed';
109+
const isScheduled = automation.trigger.type === 'scheduled';
110+
const selectedObjectFields = objects.find(o => o.name === automation.trigger.objectName)?.fields;
111+
112+
const renderActionParams = (action: ActionConfig, idx: number) => {
113+
const setParam = (key: string, value: string) =>
114+
updateAction(idx, { params: { ...action.params, [key]: value } });
115+
switch (action.type) {
116+
case 'send_email':
117+
return (<>
118+
<ParamInput label="To" value={action.params.to ?? ''} placeholder="Recipient email" onChange={v => setParam('to', v)} />
119+
<ParamInput label="Subject" value={action.params.subject ?? ''} placeholder="Email subject" onChange={v => setParam('subject', v)} />
120+
</>);
121+
case 'webhook':
122+
return <ParamInput label="URL" value={action.params.url ?? ''} placeholder="https://..." onChange={v => setParam('url', v)} />;
123+
case 'notification':
124+
return <ParamInput label="Message" value={action.params.message ?? ''} placeholder="Notification message" onChange={v => setParam('message', v)} />;
125+
case 'update_field': case 'create_record': case 'delete_record':
126+
return <ParamInput label="Target Object" value={action.params.objectName ?? ''} placeholder="Object name" onChange={v => setParam('objectName', v)} />;
127+
default: return null;
128+
}
129+
};
130+
131+
return (
132+
<div className={className ?? 'space-y-4'}>
133+
{/* Trigger Selection */}
134+
<Card>
135+
<CardHeader>
136+
<CardTitle className="flex items-center gap-2 text-sm">
137+
<Zap className="h-4 w-4 text-yellow-500" />
138+
Trigger
139+
</CardTitle>
140+
</CardHeader>
141+
<CardContent className="space-y-4">
142+
<div className="space-y-1">
143+
<Label className="text-xs">Trigger Type</Label>
144+
<Select value={automation.trigger.type} onValueChange={(v) => updateTrigger({ type: v as TriggerConfig['type'] })}>
145+
<SelectTrigger><SelectValue placeholder="Select trigger type" /></SelectTrigger>
146+
<SelectContent>
147+
{(Object.keys(TRIGGER_LABELS) as TriggerConfig['type'][]).map(t => (
148+
<SelectItem key={t} value={t}>{TRIGGER_LABELS[t]}</SelectItem>
149+
))}
150+
</SelectContent>
151+
</Select>
152+
</div>
153+
{needsObject && (
154+
<div className="space-y-1">
155+
<Label className="text-xs">Object</Label>
156+
{objects.length > 0 ? (
157+
<Select value={automation.trigger.objectName ?? ''} onValueChange={(v) => updateTrigger({ objectName: v })}>
158+
<SelectTrigger><SelectValue placeholder="Select object" /></SelectTrigger>
159+
<SelectContent>
160+
{objects.map(o => <SelectItem key={o.name} value={o.name}>{o.label}</SelectItem>)}
161+
</SelectContent>
162+
</Select>
163+
) : (
164+
<Input value={automation.trigger.objectName ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTrigger({ objectName: e.target.value })} placeholder="Object name" />
165+
)}
166+
</div>
167+
)}
168+
{needsField && (
169+
<div className="space-y-1">
170+
<Label className="text-xs">Field</Label>
171+
{selectedObjectFields ? (
172+
<Select value={automation.trigger.fieldName ?? ''} onValueChange={(v) => updateTrigger({ fieldName: v })}>
173+
<SelectTrigger><SelectValue placeholder="Select field" /></SelectTrigger>
174+
<SelectContent>
175+
{Object.keys(selectedObjectFields).map(f => <SelectItem key={f} value={f}>{f}</SelectItem>)}
176+
</SelectContent>
177+
</Select>
178+
) : (
179+
<Input value={automation.trigger.fieldName ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTrigger({ fieldName: e.target.value })} placeholder="Field name" />
180+
)}
181+
</div>
182+
)}
183+
{isScheduled && (
184+
<div className="space-y-1">
185+
<Label className="text-xs flex items-center gap-1"><Clock className="h-3 w-3" /> Cron Schedule</Label>
186+
<Input value={automation.trigger.schedule ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTrigger({ schedule: e.target.value })} placeholder="e.g. 0 9 * * 1-5" />
187+
</div>
188+
)}
189+
<div className="space-y-1">
190+
<Label className="text-xs">Condition (optional)</Label>
191+
<Input value={automation.trigger.condition ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTrigger({ condition: e.target.value })} placeholder='e.g. ${data.status === "active"}' />
192+
</div>
193+
</CardContent>
194+
</Card>
195+
196+
{/* Action Configuration */}
197+
<Card>
198+
<CardHeader>
199+
<CardTitle className="flex items-center gap-2 text-sm">
200+
<Settings className="h-4 w-4" /> Actions
201+
<Badge variant="secondary" className="ml-auto">{automation.actions.length}</Badge>
202+
</CardTitle>
203+
</CardHeader>
204+
<CardContent className="space-y-3">
205+
{automation.actions.map((action, idx) => (
206+
<div key={idx} className="rounded-lg border p-3 space-y-3">
207+
<div className="flex items-center justify-between">
208+
<div className="flex items-center gap-2">
209+
{ACTION_ICONS[action.type]}
210+
<span className="text-sm font-medium">Action {idx + 1}</span>
211+
</div>
212+
<Button size="sm" variant="ghost" className="h-7 w-7 p-0 text-red-500 hover:text-red-700" onClick={() => removeAction(idx)}>
213+
<Trash2 className="h-4 w-4" />
214+
</Button>
215+
</div>
216+
<div className="space-y-1">
217+
<Label className="text-xs">Action Type</Label>
218+
<Select value={action.type} onValueChange={(v) => updateAction(idx, { type: v as ActionConfig['type'], params: {} })}>
219+
<SelectTrigger><SelectValue /></SelectTrigger>
220+
<SelectContent>
221+
{(Object.keys(ACTION_LABELS) as ActionConfig['type'][]).map(t => (
222+
<SelectItem key={t} value={t}>{ACTION_LABELS[t]}</SelectItem>
223+
))}
224+
</SelectContent>
225+
</Select>
226+
</div>
227+
{renderActionParams(action, idx)}
228+
</div>
229+
))}
230+
<Button variant="outline" size="sm" className="w-full" onClick={addAction}>
231+
<Plus className="h-4 w-4 mr-2" /> Add Action
232+
</Button>
233+
</CardContent>
234+
</Card>
235+
236+
{/* Summary */}
237+
<Card>
238+
<CardHeader><CardTitle className="text-sm">Summary</CardTitle></CardHeader>
239+
<CardContent className="space-y-4">
240+
<div className="space-y-1">
241+
<Label className="text-xs">Name</Label>
242+
<Input value={automation.name} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAutomation(prev => ({ ...prev, name: e.target.value }))} placeholder="Automation name" />
243+
</div>
244+
<div className="space-y-1">
245+
<Label className="text-xs">Description</Label>
246+
<Input value={automation.description ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAutomation(prev => ({ ...prev, description: e.target.value }))} placeholder="Optional description" />
247+
</div>
248+
<div className="flex items-center justify-between">
249+
<Label className="text-sm">Enabled</Label>
250+
<Switch checked={automation.enabled} onCheckedChange={(checked: boolean) => setAutomation(prev => ({ ...prev, enabled: checked }))} />
251+
</div>
252+
<Separator />
253+
<div className="flex items-center gap-2 justify-end">
254+
{onCancel && <Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>}
255+
<Button size="sm" onClick={() => onSave?.(automation)}>
256+
<Zap className="h-4 w-4 mr-2" /> Save Automation
257+
</Button>
258+
</div>
259+
</CardContent>
260+
</Card>
261+
</div>
262+
);
263+
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React from 'react';
10+
import { Card, CardContent, CardHeader, CardTitle, Badge } from '@object-ui/components';
11+
import { Clock } from 'lucide-react';
12+
13+
export interface AutomationRun {
14+
id: string;
15+
automationId: string;
16+
automationName: string;
17+
status: 'success' | 'failure' | 'running' | 'pending';
18+
startedAt: string;
19+
completedAt?: string;
20+
triggerEvent?: string;
21+
error?: string;
22+
}
23+
24+
export interface AutomationRunHistoryProps {
25+
runs?: AutomationRun[];
26+
className?: string;
27+
}
28+
29+
const STATUS_VARIANT: Record<AutomationRun['status'], 'default' | 'destructive' | 'secondary' | 'outline'> = {
30+
success: 'default',
31+
failure: 'destructive',
32+
running: 'secondary',
33+
pending: 'outline',
34+
};
35+
36+
const STATUS_LABEL: Record<AutomationRun['status'], string> = {
37+
success: 'Success',
38+
failure: 'Failed',
39+
running: 'Running',
40+
pending: 'Pending',
41+
};
42+
43+
function formatDuration(start: string, end?: string): string {
44+
if (!end) return '—';
45+
const ms = new Date(end).getTime() - new Date(start).getTime();
46+
if (ms < 1000) return `${ms}ms`;
47+
const secs = Math.floor(ms / 1000);
48+
if (secs < 60) return `${secs}s`;
49+
const mins = Math.floor(secs / 60);
50+
const rem = secs % 60;
51+
return `${mins}m ${rem}s`;
52+
}
53+
54+
/**
55+
* AutomationRunHistory - Displays a list of past automation executions
56+
* Shows status, automation name, trigger event, timing, and errors.
57+
*/
58+
export const AutomationRunHistory: React.FC<AutomationRunHistoryProps> = ({
59+
runs = [],
60+
className,
61+
}) => {
62+
return (
63+
<Card className={className}>
64+
<CardHeader>
65+
<CardTitle className="flex items-center gap-2 text-sm">
66+
<Clock className="h-4 w-4" />
67+
Automation Run History
68+
<Badge variant="outline" className="ml-auto">{runs.length}</Badge>
69+
</CardTitle>
70+
</CardHeader>
71+
<CardContent>
72+
{runs.length === 0 ? (
73+
<p className="text-sm text-muted-foreground text-center py-6">
74+
No automation runs yet
75+
</p>
76+
) : (
77+
<div className="space-y-2">
78+
{runs.map(run => (
79+
<div key={run.id} className="flex items-start gap-3 rounded-lg border p-3">
80+
<Badge variant={STATUS_VARIANT[run.status]}>{STATUS_LABEL[run.status]}</Badge>
81+
<div className="flex-1 min-w-0 space-y-1">
82+
<div className="flex items-center gap-2 flex-wrap">
83+
<span className="text-sm font-medium">{run.automationName}</span>
84+
{run.triggerEvent && (
85+
<span className="text-xs text-muted-foreground">{run.triggerEvent}</span>
86+
)}
87+
</div>
88+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
89+
<span>{new Date(run.startedAt).toLocaleString()}</span>
90+
<span>Duration: {formatDuration(run.startedAt, run.completedAt)}</span>
91+
</div>
92+
{run.status === 'failure' && run.error && (
93+
<p className="text-xs text-red-600 mt-1">{run.error}</p>
94+
)}
95+
</div>
96+
</div>
97+
))}
98+
</div>
99+
)}
100+
</CardContent>
101+
</Card>
102+
);
103+
};

0 commit comments

Comments
 (0)