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.
+ {mappings.map((row, index) => (
+
+
+
updateRow(index, { contact: event.target.value })}
+ />
+ {row.contact === 'msl_code' ? (
+ onChange({ msl_mode: event.target.value })}
+ >
+
+
+
+ ) : (
+ updateRow(index, { source: event.target.value })}
+ />
+ )}
+ {row.locked && !row.removable ? : (
+
+ )}
+
+ {row.contact === 'msl_code' && config.msl_mode === 'auto' ? (
+
+
onChange({ msl_prefix: event.target.value })} />
+ onChange({ msl_suffix: event.target.value })} />
+
+ ) : null}
+ {row.contact === 'msl_code' && config.msl_mode !== 'auto' ? (
+
updateRow(index, { source: event.target.value })}
+ />
+ ) : null}
+
+ ))}
+ onChange({ mandatory: event.target.checked })}
+ />
+
+ Save text as
+ onChange({ output: event.target.value })} />
+
+ >
+ );
+ }
+ if (type === 'delay') {
+ return (
+
+ Seconds
+ onChange({ seconds: Number(event.target.value) })}
+ />
+ The run waits at least this long. EventBridge fires on a one-minute grid, so it can take up to about a minute longer.
+
+ );
+ }
+ if (type === 'llm') {
+ return (
+ <>
+
+ Model
+ onChange({ model: event.target.value })}>
+
+
+
+
+
+
+ Prompt
+ onChange({ prompt: event.target.value })}
+ />
+ Use {'{{doctor_name}}'} or {'{{ocr_text}}'} for a value from the incoming object.
+
+
+ Save answer as
+ onChange({ output: event.target.value })} />
+
+ >
+ );
+ }
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 (
<>
Action
- onChange({ name: event.target.value })}>
+ {
+ const name = event.target.value;
+ onChange(name === 'update_submission'
+ ? { name, mappings: mappings.length ? mappings : [{ field: '', source: '' }] }
+ : { name });
+ }}
+ >
+
-
- Status
- onChange({ status: event.target.value })} />
-
+ {config.name === 'update_submission' ? (
+ <>
+ 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.
+ {mappings.map((row, index) => (
+
+
updateRow(index, { field: event.target.value })}
+ />
+ updateRow(index, { source: event.target.value })}
+ />
+
+
+ ))}
+
+ >
+ ) : (
+
+ Status
+ onChange({ status: event.target.value })} />
+
+ )}
>
);
}
@@ -401,7 +636,7 @@ function EdgeForm({ edge, onChange }) {
Operator
onChange({ op: event.target.value })}>
- {['eq', 'neq', 'in', 'gt', 'lt', 'exists'].map((op) => (
+ {['eq', 'neq', 'in', 'gt', 'lt', 'exists', 'empty'].map((op) => (
))}
diff --git a/src/views/workflow/graph.js b/src/views/workflow/graph.js
index 8300d5e..5e2ce0c 100644
--- a/src/views/workflow/graph.js
+++ b/src/views/workflow/graph.js
@@ -4,16 +4,63 @@ export const NODE_TYPES = [
{ type: 'approval', label: 'Approval' },
{ type: 'notify', label: 'Notify' },
{ type: 'action', label: 'Action' },
+ { type: 'contact', label: 'Contact' },
+ { type: 'ocr', label: 'OCR' },
+ { type: 'llm', label: 'LLM' },
+ { type: 'delay', label: 'Delay' },
{ type: 'end', label: 'End' },
];
+export const CONTACT_FIELDS = [
+ { contact: 'id', optional: true },
+ { contact: 'name' },
+ { contact: 'phone', optional: true },
+ { contact: 'email', optional: true },
+ { contact: 'msl_code', optional: true, removable: true },
+ { contact: 'type' },
+ { contact: 'hierarchy' },
+ { contact: 'user_id' },
+];
+
+export function contactMappings(config) {
+ const saved = Array.isArray(config && config.mappings) ? config.mappings : [];
+ const required = CONTACT_FIELDS.filter((field) => !field.removable || saved.some((row) => row && row.contact === field.contact)).map((field) => {
+ const found = saved.find((row) => row && row.contact === field.contact);
+ return {
+ contact: field.contact,
+ source: (found && found.source) || '',
+ locked: true,
+ optional: Boolean(field.optional),
+ removable: Boolean(field.removable),
+ };
+ });
+ const extra = saved
+ .filter((row) => {
+ if (!row) return false;
+ const contact = String(row.contact || '').trim();
+ if (['position', 'user_info', 'domain', 'application'].includes(contact)) return false;
+ if (contact && CONTACT_FIELDS.some((field) => field.contact === contact)) return false;
+ return true;
+ })
+ .map((row) => ({ contact: row.contact || '', source: row.source || '', locked: false }));
+ return required.concat(extra);
+}
+
export function labelFor(node) {
const type = node.type || node.data?.nodeType;
const config = node.config || node.data?.config || {};
if (type === 'approval') return `Approval (${config.mode || 'single'})`;
if (type === 'end') return `End (${config.outcome || 'completed'})`;
if (type === 'notify') return 'Notify';
- if (type === 'action') return config.name || 'Action';
+ if (type === 'action') {
+ if (config.name === 'update_submission') return 'Form submission update';
+ if (config.name === 'set_status') return 'Set status';
+ return config.name || 'Action';
+ }
+ if (type === 'contact') return 'Update contact';
+ if (type === 'ocr') return 'OCR';
+ if (type === 'llm') return 'LLM';
+ if (type === 'delay') return `Delay (${config.seconds || 60}s)`;
if (type === 'condition') return 'Condition';
return 'Start';
}
@@ -45,12 +92,44 @@ export function toFlow(definition) {
};
}
+function actionConfig(config) {
+ const current = config || {};
+ if (current.name === 'update_submission') {
+ const mappings = Array.isArray(current.mappings) ? current.mappings : [];
+ return {
+ name: 'update_submission',
+ mappings: mappings
+ .map((row) => ({
+ field: String((row && row.field) || '').trim(),
+ source: String((row && row.source) || '').trim(),
+ }))
+ .filter((row) => row.field || row.source),
+ };
+ }
+ return {
+ name: current.name || 'set_status',
+ status: current.status || '',
+ };
+}
+
export function fromFlow(nodes, edges) {
return {
nodes: nodes.map((node) => ({
id: node.id,
type: node.data.nodeType,
- config: node.data.config || {},
+ config: node.data.nodeType === 'contact'
+ ? {
+ ...node.data.config,
+ msl_mode: node.data.config?.msl_mode === 'auto' ? 'auto' : 'map',
+ msl_prefix: node.data.config?.msl_prefix || '',
+ msl_suffix: node.data.config?.msl_suffix || '',
+ mappings: contactMappings(node.data.config)
+ .filter((row) => String(row.contact || '').trim())
+ .map(({ contact, source }) => ({ contact, source })),
+ }
+ : node.data.nodeType === 'action'
+ ? actionConfig(node.data.config)
+ : node.data.config || {},
})),
edges: edges.map((edge) => {
const when = edge.data && edge.data.when && edge.data.when.field ? edge.data.when : null;
@@ -69,6 +148,17 @@ export function defaultConfig(type) {
if (type === 'approval') return { mode: 'sequential', approvers: ['l1manager'], escalate_vacant: true };
if (type === 'notify') return { recipients: ['l1manager'], subject: '', body: '' };
if (type === 'action') return { name: 'set_status', status: 'approved' };
+ if (type === 'contact') {
+ return {
+ mappings: contactMappings({ mappings: [{ contact: 'msl_code', source: '' }] }).map(({ contact, source }) => ({ contact, source })),
+ msl_mode: 'map',
+ msl_prefix: '',
+ msl_suffix: '',
+ };
+ }
+ if (type === 'ocr') return { source: '', output: 'ocr_text', mandatory: false };
+ if (type === 'llm') return { model: 'meta.llama3-8b-instruct-v1:0', prompt: '', output: 'llm_text' };
+ if (type === 'delay') return { seconds: 60 };
if (type === 'end') return { outcome: 'completed' };
return {};
}