651 lines
25 KiB
JavaScript
651 lines
25 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { Link, useSearchParams } from 'react-router';
|
||
import {
|
||
Background,
|
||
Controls,
|
||
MiniMap,
|
||
Panel,
|
||
ReactFlow,
|
||
addEdge,
|
||
useEdgesState,
|
||
useNodesState,
|
||
} from '@xyflow/react';
|
||
import '@xyflow/react/dist/style.css';
|
||
import { Button, Card, Col, Container, Form, Modal, Row, Spinner } from 'react-bootstrap';
|
||
import Select from 'react-select';
|
||
import PageBreadcrumb from '@/components/PageBreadcrumb';
|
||
import { isSuccess, signedGet, signedPost } from '@/api/client';
|
||
import { dropdownMap } from '@/api/dropdowns';
|
||
import { defaultConfig, fromFlow, labelFor, toFlow, NODE_TYPES, edgeLabel, contactMappings } from '@/views/workflow/graph';
|
||
|
||
function newId() {
|
||
return crypto.randomUUID();
|
||
}
|
||
|
||
const MANAGER_LEVELS = [
|
||
{ value: 'l1manager', label: 'L1 Manager' },
|
||
{ value: 'l2manager', label: 'L2 Manager' },
|
||
{ value: 'l3manager', label: 'L3 Manager' },
|
||
{ value: 'l4manager', label: 'L4 Manager' },
|
||
];
|
||
|
||
function approverGroups(roles, users) {
|
||
return [
|
||
{ label: 'Manager', options: MANAGER_LEVELS },
|
||
{ label: 'Roles', options: Object.entries(roles).map(([value, label]) => ({ value, label })) },
|
||
{ label: 'Users', options: Object.entries(users).map(([value, label]) => ({ value, label })) },
|
||
];
|
||
}
|
||
|
||
function ApproverSelect({ values, groups, onChange }) {
|
||
const known = groups.flatMap((group) => group.options || []);
|
||
const saved = (values || []).filter((value) => !known.some((option) => option.value === value)).map((value) => ({ value, label: value }));
|
||
const options = saved.length ? groups.concat({ label: 'Saved', options: saved }) : groups;
|
||
const byValue = new Map(known.concat(saved).map((option) => [option.value, option]));
|
||
return (
|
||
<Select
|
||
isMulti
|
||
options={options}
|
||
classNamePrefix="react-select"
|
||
menuPosition="fixed"
|
||
menuPortalTarget={typeof document !== 'undefined' ? document.body : null}
|
||
styles={{ menuPortal: (base) => ({ ...base, zIndex: 2000 }) }}
|
||
value={(values || []).map((value) => byValue.get(value)).filter(Boolean)}
|
||
onChange={(selected) => onChange((selected || []).map((option) => option.value))}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function WorkflowEditor() {
|
||
const [params] = useSearchParams();
|
||
const id = params.get('id') || '';
|
||
const [item, setItem] = useState(null);
|
||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||
const [selected, setSelected] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [message, setMessage] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [errors, setErrors] = useState([]);
|
||
const [contextText, setContextText] = useState('{}');
|
||
const [hierarchyId, setHierarchyId] = useState('');
|
||
const [testOpen, setTestOpen] = useState(false);
|
||
const [approverOptions, setApproverOptions] = useState(() => approverGroups({}, {}));
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
setLoading(true);
|
||
setError('');
|
||
const response = await signedGet('workflow', 'workflow_definition/get_one', { id });
|
||
if (!isSuccess(response)) {
|
||
setError(response?.message || 'Unable to load workflow');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
const definition = response.data.item;
|
||
const flow = toFlow(definition);
|
||
setItem(definition);
|
||
setNodes(flow.nodes);
|
||
setEdges(flow.edges);
|
||
setLoading(false);
|
||
};
|
||
if (id) load();
|
||
}, [id, setEdges, setNodes]);
|
||
|
||
useEffect(() => {
|
||
const loadApprovers = async () => {
|
||
try {
|
||
const [roles, usersRes] = await Promise.all([
|
||
dropdownMap('ums', 'roles', (role) => role.name),
|
||
signedGet('ums', 'users/dropdown'),
|
||
]);
|
||
const users = {};
|
||
const items = isSuccess(usersRes) ? usersRes.data?.items || [] : [];
|
||
items.forEach((user) => {
|
||
if (!user?.id) return;
|
||
users[user.id] = `${user.firstname || ''} ${user.lastname || ''} (${user.employee_code || ''})`.trim();
|
||
});
|
||
setApproverOptions(approverGroups(roles || {}, users));
|
||
} catch {
|
||
setApproverOptions(approverGroups({}, {}));
|
||
}
|
||
};
|
||
loadApprovers();
|
||
}, []);
|
||
|
||
const selectedNode = useMemo(
|
||
() => nodes.find((node) => node.id === selected && node.data),
|
||
[nodes, selected]
|
||
);
|
||
const selectedEdge = useMemo(() => edges.find((edge) => edge.id === selected), [edges, selected]);
|
||
|
||
const onConnect = useCallback(
|
||
(connection) => {
|
||
setEdges((current) =>
|
||
addEdge(
|
||
{
|
||
...connection,
|
||
id: newId(),
|
||
data: { when: null },
|
||
},
|
||
current
|
||
)
|
||
);
|
||
},
|
||
[setEdges]
|
||
);
|
||
|
||
const addNode = (type) => {
|
||
const config = defaultConfig(type);
|
||
const node = {
|
||
id: newId(),
|
||
position: { x: 120 + nodes.length * 24, y: 80 + nodes.length * 24 },
|
||
data: { nodeType: type, config, label: labelFor({ type, config }) },
|
||
};
|
||
setNodes((current) => current.concat(node));
|
||
};
|
||
|
||
const patchNode = (config) => {
|
||
setNodes((current) =>
|
||
current.map((node) => {
|
||
if (node.id !== selectedNode.id) return node;
|
||
const next = { ...node.data.config, ...config };
|
||
return { ...node, data: { ...node.data, config: next, label: labelFor({ type: node.data.nodeType, config: next }) } };
|
||
})
|
||
);
|
||
};
|
||
|
||
const deleteNode = (nodeId) => {
|
||
setNodes((current) => current.filter((node) => node.id !== nodeId));
|
||
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
|
||
setSelected(null);
|
||
};
|
||
|
||
const deleteEdge = (edgeId) => {
|
||
setEdges((current) => current.filter((edge) => edge.id !== edgeId));
|
||
setSelected(null);
|
||
};
|
||
|
||
const onNodesDelete = useCallback((deleted) => {
|
||
const ids = new Set(deleted.map((node) => node.id));
|
||
setEdges((current) => current.filter((edge) => !ids.has(edge.source) && !ids.has(edge.target)));
|
||
setSelected(null);
|
||
}, [setEdges]);
|
||
|
||
const patchEdge = (when) => {
|
||
setEdges((current) =>
|
||
current.map((edge) => {
|
||
if (edge.id !== selectedEdge.id) return edge;
|
||
const next = { ...(edge.data?.when || {}), ...when };
|
||
const stored = next.field ? next : null;
|
||
return { ...edge, data: { when: stored }, label: edgeLabel(stored) };
|
||
})
|
||
);
|
||
};
|
||
|
||
const persist = async (publish) => {
|
||
setSaving(true);
|
||
setError('');
|
||
setMessage('');
|
||
setErrors([]);
|
||
const graph = fromFlow(nodes, edges);
|
||
const saved = await signedPost('workflow', 'workflow_definition/update', {
|
||
id,
|
||
name: item.name,
|
||
entity: item.entity,
|
||
description: item.description || '',
|
||
...graph,
|
||
});
|
||
if (!isSuccess(saved)) {
|
||
setError(saved?.message || 'Save failed');
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
if (!publish) {
|
||
setMessage('Draft saved');
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
const published = await signedPost('workflow', 'workflow_definition/publish', { id });
|
||
if (!isSuccess(published)) {
|
||
setErrors(published?.data?.errors || []);
|
||
setError(published?.message || 'Publish failed');
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
setItem((current) => ({ ...current, published_version: published.data.item.published_version }));
|
||
setMessage(`Published version ${published.data.item.published_version}`);
|
||
setSaving(false);
|
||
};
|
||
|
||
const runTest = async () => {
|
||
setError('');
|
||
setMessage('');
|
||
let context = {};
|
||
try {
|
||
context = contextText.trim() ? JSON.parse(contextText) : {};
|
||
} catch {
|
||
setError('Test context must be JSON');
|
||
return;
|
||
}
|
||
const response = await signedPost('workflow', 'workflow_instance/start', {
|
||
definition_id: id,
|
||
entity: 'manual',
|
||
entity_id: `test-${Date.now()}`,
|
||
hierarchy_id: hierarchyId,
|
||
context,
|
||
});
|
||
if (!isSuccess(response)) {
|
||
setError(response?.message || 'Test run failed');
|
||
return;
|
||
}
|
||
window.location.assign(`/workflow/monitor?id=${response.data.item.id}`);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<Container className="py-4">
|
||
<Spinner animation="border" size="sm" /> Loading workflow
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Container fluid className="py-3">
|
||
<PageBreadcrumb title={item?.name || 'Workflow'} subtitle="Workflows" />
|
||
<div className="d-flex gap-2 mb-3">
|
||
<Button as={Link} to="/workflows" variant="light">Back</Button>
|
||
<Button disabled={saving} onClick={() => persist(false)}>Save draft</Button>
|
||
<Button disabled={saving} variant="primary" onClick={() => persist(true)}>Publish</Button>
|
||
<Button variant="outline-primary" onClick={() => setTestOpen(true)}>Test run</Button>
|
||
<span className="text-muted align-self-center">
|
||
Published v{item?.published_version || 0}
|
||
</span>
|
||
</div>
|
||
{message ? <div className="alert alert-success">{message}</div> : null}
|
||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||
{errors.length ? (
|
||
<ul className="text-danger">
|
||
{errors.map((itemError) => (
|
||
<li key={`${itemError.message}-${(itemError.nodeIds || []).join(',')}`}>
|
||
{itemError.message}
|
||
{itemError.nodeIds?.length ? ` (${itemError.nodeIds.join(', ')})` : ''}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
<Row>
|
||
<Col md={2}>
|
||
<Card>
|
||
<Card.Header>Add node</Card.Header>
|
||
<Card.Body className="d-grid gap-2">
|
||
{NODE_TYPES.map((entry) => (
|
||
<Button key={entry.type} variant="outline-secondary" onClick={() => addNode(entry.type)}>
|
||
{entry.label}
|
||
</Button>
|
||
))}
|
||
</Card.Body>
|
||
</Card>
|
||
</Col>
|
||
<Col>
|
||
<div style={{ height: '70vh', border: '1px solid #dee2e6', borderRadius: 8 }}>
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
onConnect={onConnect}
|
||
onNodesDelete={onNodesDelete}
|
||
deleteKeyCode={['Backspace', 'Delete']}
|
||
edgesReconnectable
|
||
onPaneClick={() => setSelected(null)}
|
||
onNodeClick={(_, node) => setSelected(node.id)}
|
||
onEdgeClick={(_, edge) => setSelected(edge.id)}
|
||
fitView
|
||
>
|
||
<MiniMap />
|
||
<Controls />
|
||
<Background />
|
||
{selectedNode || selectedEdge ? (
|
||
<Panel position="top-right">
|
||
<Card className="d-flex flex-column" style={{ width: 360, maxHeight: 'calc(70vh - 24px)', overflow: 'hidden' }}>
|
||
<Card.Header className="d-flex align-items-center justify-content-between flex-shrink-0">
|
||
<span>{selectedNode ? selectedNode.data.label : 'Relation'}</span>
|
||
<button type="button" className="btn-close" aria-label="Close" onClick={() => setSelected(null)} />
|
||
</Card.Header>
|
||
<Card.Body className="overflow-auto" style={{ minHeight: 0 }}>
|
||
{selectedNode ? (
|
||
<>
|
||
<NodeForm node={selectedNode} approverOptions={approverOptions} onChange={patchNode} />
|
||
<Button variant="outline-danger" className="mt-3" onClick={() => deleteNode(selectedNode.id)}>
|
||
Delete node
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<EdgeForm edge={selectedEdge} onChange={patchEdge} />
|
||
<Button variant="outline-danger" className="mt-3" onClick={() => deleteEdge(selectedEdge.id)}>
|
||
Delete relation
|
||
</Button>
|
||
</>
|
||
)}
|
||
</Card.Body>
|
||
</Card>
|
||
</Panel>
|
||
) : null}
|
||
</ReactFlow>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
<Modal show={testOpen} onHide={() => setTestOpen(false)}>
|
||
<Modal.Header closeButton>
|
||
<Modal.Title>Test run</Modal.Title>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
<Form.Group className="mb-3">
|
||
<Form.Label>Hierarchy id</Form.Label>
|
||
<Form.Control value={hierarchyId} onChange={(event) => setHierarchyId(event.target.value)} />
|
||
</Form.Group>
|
||
<Form.Group>
|
||
<Form.Label>Context JSON</Form.Label>
|
||
<Form.Control as="textarea" rows={8} value={contextText} onChange={(event) => setContextText(event.target.value)} />
|
||
</Form.Group>
|
||
</Modal.Body>
|
||
<Modal.Footer>
|
||
<Button variant="light" onClick={() => setTestOpen(false)}>Cancel</Button>
|
||
<Button variant="primary" onClick={runTest} disabled={!item?.published_version}>
|
||
Run published version
|
||
</Button>
|
||
</Modal.Footer>
|
||
</Modal>
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
function NodeForm({ node, approverOptions, onChange }) {
|
||
const config = node.data.config || {};
|
||
const type = node.data.nodeType;
|
||
if (type === 'approval') {
|
||
return (
|
||
<>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Mode</Form.Label>
|
||
<Form.Select value={config.mode || 'single'} onChange={(event) => onChange({ mode: event.target.value })}>
|
||
<option value="single">Single</option>
|
||
<option value="all">All</option>
|
||
<option value="sequential">Sequential</option>
|
||
</Form.Select>
|
||
</Form.Group>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Approvers</Form.Label>
|
||
<ApproverSelect
|
||
values={config.approvers || []}
|
||
groups={approverOptions}
|
||
onChange={(approvers) => onChange({ approvers })}
|
||
/>
|
||
<Form.Text>Pick manager levels, roles, or users. In Sequential mode, the order selected is the approval order.</Form.Text>
|
||
</Form.Group>
|
||
<Form.Check
|
||
label="Escalate when the approver seat is vacant"
|
||
checked={config.escalate_vacant !== false}
|
||
onChange={(event) => onChange({ escalate_vacant: event.target.checked })}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
if (type === 'notify') {
|
||
return (
|
||
<>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Recipients</Form.Label>
|
||
<Form.Control
|
||
value={(config.recipients || []).join(', ')}
|
||
onChange={(event) => onChange({ recipients: event.target.value.split(',').map((part) => part.trim()).filter(Boolean) })}
|
||
/>
|
||
</Form.Group>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Subject</Form.Label>
|
||
<Form.Control value={config.subject || ''} onChange={(event) => onChange({ subject: event.target.value })} />
|
||
</Form.Group>
|
||
<Form.Group>
|
||
<Form.Label>Body</Form.Label>
|
||
<Form.Control as="textarea" rows={3} value={config.body || ''} onChange={(event) => onChange({ body: event.target.value })} />
|
||
</Form.Group>
|
||
</>
|
||
);
|
||
}
|
||
if (type === 'contact') {
|
||
const mappings = contactMappings(config);
|
||
const updateRow = (index, patch) => {
|
||
const next = mappings.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
|
||
onChange({ mappings: next.map(({ contact, source }) => ({ contact, source })) });
|
||
};
|
||
return (
|
||
<>
|
||
<p className="text-muted">Map each contact field to a field on the incoming object. If that field is not on the object, the text is saved as the value. Phone or email is enough. Application and domain come from this workflow. Position comes from the hierarchy, and user info is built from the user.</p>
|
||
{mappings.map((row, index) => (
|
||
<div className="mb-2" key={`${row.contact}-${index}`}>
|
||
<div className="d-flex gap-2">
|
||
<Form.Control
|
||
placeholder="Contact field"
|
||
value={row.contact || ''}
|
||
disabled={row.locked}
|
||
onChange={(event) => updateRow(index, { contact: event.target.value })}
|
||
/>
|
||
{row.contact === 'msl_code' ? (
|
||
<Form.Select
|
||
value={config.msl_mode === 'auto' ? 'auto' : 'map'}
|
||
onChange={(event) => onChange({ msl_mode: event.target.value })}
|
||
>
|
||
<option value="map">Map field</option>
|
||
<option value="auto">Autocreate</option>
|
||
</Form.Select>
|
||
) : (
|
||
<Form.Control
|
||
placeholder={row.optional ? 'Incoming field' : 'Incoming field *'}
|
||
value={row.source || ''}
|
||
onChange={(event) => updateRow(index, { source: event.target.value })}
|
||
/>
|
||
)}
|
||
{row.locked && !row.removable ? <span style={{ width: 38 }} /> : (
|
||
<Button
|
||
variant="outline-danger"
|
||
onClick={() => onChange({ mappings: mappings.filter((_, rowIndex) => rowIndex !== index).map(({ contact, source }) => ({ contact, source })) })}
|
||
>
|
||
×
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{row.contact === 'msl_code' && config.msl_mode === 'auto' ? (
|
||
<div className="d-flex gap-2 mt-2">
|
||
<Form.Control placeholder="Prefix" value={config.msl_prefix || ''} onChange={(event) => onChange({ msl_prefix: event.target.value })} />
|
||
<Form.Control placeholder="Suffix" value={config.msl_suffix || ''} onChange={(event) => onChange({ msl_suffix: event.target.value })} />
|
||
</div>
|
||
) : null}
|
||
{row.contact === 'msl_code' && config.msl_mode !== 'auto' ? (
|
||
<Form.Control
|
||
className="mt-2"
|
||
placeholder="Incoming field *"
|
||
value={row.source || ''}
|
||
onChange={(event) => updateRow(index, { source: event.target.value })}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
<Button variant="outline-secondary" onClick={() => onChange({ mappings: mappings.concat({ contact: '', source: '' }).map(({ contact, source }) => ({ contact, source })) })}>
|
||
Add field
|
||
</Button>
|
||
</>
|
||
);
|
||
}
|
||
if (type === 'ocr') {
|
||
return (
|
||
<>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Document</Form.Label>
|
||
<Form.Control
|
||
placeholder="visiting_card"
|
||
value={config.source || ''}
|
||
onChange={(event) => onChange({ source: event.target.value })}
|
||
/>
|
||
<Form.Text>Incoming field or a document URL. Textract reads the file. A missing field is skipped.</Form.Text>
|
||
</Form.Group>
|
||
<Form.Check
|
||
className="mb-2"
|
||
label="Mandatory"
|
||
checked={config.mandatory === true}
|
||
onChange={(event) => onChange({ mandatory: event.target.checked })}
|
||
/>
|
||
<Form.Group>
|
||
<Form.Label>Save text as</Form.Label>
|
||
<Form.Control value={config.output || 'ocr_text'} onChange={(event) => onChange({ output: event.target.value })} />
|
||
</Form.Group>
|
||
</>
|
||
);
|
||
}
|
||
if (type === 'delay') {
|
||
return (
|
||
<Form.Group>
|
||
<Form.Label>Seconds</Form.Label>
|
||
<Form.Control
|
||
type="number"
|
||
min={1}
|
||
max={86400}
|
||
value={config.seconds ?? 60}
|
||
onChange={(event) => onChange({ seconds: Number(event.target.value) })}
|
||
/>
|
||
<Form.Text>The run waits at least this long. EventBridge fires on a one-minute grid, so it can take up to about a minute longer.</Form.Text>
|
||
</Form.Group>
|
||
);
|
||
}
|
||
if (type === 'llm') {
|
||
return (
|
||
<>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Model</Form.Label>
|
||
<Form.Select value={config.model || 'meta.llama3-8b-instruct-v1:0'} onChange={(event) => onChange({ model: event.target.value })}>
|
||
<option value="meta.llama3-8b-instruct-v1:0">Llama 3 8B</option>
|
||
<option value="amazon.titan-text-express-v1">Titan Text Express</option>
|
||
<option value="anthropic.claude-3-haiku-20240307-v1:0">Claude 3 Haiku</option>
|
||
</Form.Select>
|
||
</Form.Group>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Prompt</Form.Label>
|
||
<Form.Control
|
||
as="textarea"
|
||
rows={6}
|
||
value={config.prompt || ''}
|
||
onChange={(event) => onChange({ prompt: event.target.value })}
|
||
/>
|
||
<Form.Text>Use {'{{doctor_name}}'} or {'{{ocr_text}}'} for a value from the incoming object.</Form.Text>
|
||
</Form.Group>
|
||
<Form.Group>
|
||
<Form.Label>Save answer as</Form.Label>
|
||
<Form.Control value={config.output || 'llm_text'} onChange={(event) => onChange({ output: event.target.value })} />
|
||
</Form.Group>
|
||
</>
|
||
);
|
||
}
|
||
if (type === 'action') {
|
||
const mappings = Array.isArray(config.mappings) ? config.mappings : [];
|
||
const updateRow = (index, patch) => {
|
||
onChange({
|
||
mappings: mappings.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)),
|
||
});
|
||
};
|
||
return (
|
||
<>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Action</Form.Label>
|
||
<Form.Select
|
||
value={config.name || 'set_status'}
|
||
onChange={(event) => {
|
||
const name = event.target.value;
|
||
onChange(name === 'update_submission'
|
||
? { name, mappings: mappings.length ? mappings : [{ field: '', source: '' }] }
|
||
: { name });
|
||
}}
|
||
>
|
||
<option value="set_status">Set status</option>
|
||
<option value="update_submission">Form submission update</option>
|
||
</Form.Select>
|
||
</Form.Group>
|
||
{config.name === 'update_submission' ? (
|
||
<>
|
||
<p className="text-muted">Map each submission field to a field on this run. If that field is not on the run, the text is saved as the value. Use formdata.doctor_name for an answer, or workflow_status for the submission record.</p>
|
||
{mappings.map((row, index) => (
|
||
<div className="d-flex gap-2 mb-2" key={`submission-field-${index}`}>
|
||
<Form.Control
|
||
placeholder="Submission field"
|
||
value={row.field || ''}
|
||
onChange={(event) => updateRow(index, { field: event.target.value })}
|
||
/>
|
||
<Form.Control
|
||
placeholder="Incoming field"
|
||
value={row.source || ''}
|
||
onChange={(event) => updateRow(index, { source: event.target.value })}
|
||
/>
|
||
<Button
|
||
variant="outline-danger"
|
||
onClick={() => onChange({ mappings: mappings.filter((_, rowIndex) => rowIndex !== index) })}
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button variant="outline-secondary" onClick={() => onChange({ mappings: mappings.concat({ field: '', source: '' }) })}>
|
||
Add field
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Form.Group>
|
||
<Form.Label>Status</Form.Label>
|
||
<Form.Control value={config.status || ''} onChange={(event) => onChange({ status: event.target.value })} />
|
||
</Form.Group>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
if (type === 'end') {
|
||
return (
|
||
<Form.Group>
|
||
<Form.Label>Outcome</Form.Label>
|
||
<Form.Select value={config.outcome || 'completed'} onChange={(event) => onChange({ outcome: event.target.value })}>
|
||
<option value="approved">Approved</option>
|
||
<option value="rejected">Rejected</option>
|
||
<option value="completed">Completed</option>
|
||
</Form.Select>
|
||
</Form.Group>
|
||
);
|
||
}
|
||
if (type === 'condition') {
|
||
return <p className="text-muted mb-0">Set the branch on each outgoing edge. One edge can stay empty as the default.</p>;
|
||
}
|
||
return <p className="text-muted mb-0">Start has no settings.</p>;
|
||
}
|
||
|
||
function EdgeForm({ edge, onChange }) {
|
||
const when = edge.data?.when || { field: '', op: 'eq', value: '' };
|
||
return (
|
||
<>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Field</Form.Label>
|
||
<Form.Control value={when.field || ''} onChange={(event) => onChange({ field: event.target.value })} />
|
||
</Form.Group>
|
||
<Form.Group className="mb-2">
|
||
<Form.Label>Operator</Form.Label>
|
||
<Form.Select value={when.op || 'eq'} onChange={(event) => onChange({ op: event.target.value })}>
|
||
{['eq', 'neq', 'in', 'gt', 'lt', 'exists', 'empty'].map((op) => (
|
||
<option key={op} value={op}>{op}</option>
|
||
))}
|
||
</Form.Select>
|
||
</Form.Group>
|
||
<Form.Group>
|
||
<Form.Label>Value</Form.Label>
|
||
<Form.Control value={when.value ?? ''} onChange={(event) => onChange({ value: event.target.value })} />
|
||
</Form.Group>
|
||
</>
|
||
);
|
||
}
|