diff --git a/src/api/client.ts b/src/api/client.ts index a298628..3f28527 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -230,6 +230,30 @@ export class ZinoClient { }); } + // --- WF Lookup Records (cross-workflow references) --- + + /** Fetch records for a wf_lookup field. */ + wfLookupRecords( + opts: { + activityId: string; + fieldId: string; + formData?: Record; + search?: string; + limit?: number; + offset?: number; + } + ): Promise<{ data: Array>; records?: Array> } | Array>> { + return this.request('POST', `/app/${APP_ID}/wf-lookup/records`, { + workflow_uuid: this.workflowUuid, + activity_id: opts.activityId, + field_id: opts.fieldId, + form_data: opts.formData ?? {}, + search: opts.search ?? '', + limit: opts.limit ?? 100, + offset: opts.offset ?? 0, + }); + } + // --- Dataset-backed select options (state/city cascade etc.) --- /** Fetch options for a dataset-backed select. The server resolves the field's diff --git a/src/api/types.ts b/src/api/types.ts index c0ec80c..3628bf7 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -153,6 +153,12 @@ export interface FormScreenResponse { data?: Record; /** Existing instance data is sometimes returned as prefill_data. */ prefill_data?: Record; + /** Subsequent activities to run in a chain after this form completes. */ + activity_chain?: Array<{ + activity_uid: string; + activity_name: string; + prefill_mappings?: any[]; + }>; } // --- Audit (GET /app/{appId}/view/audit) --- diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index a7b5aae..9509a9f 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -14,6 +14,8 @@ import { TextAreaField, DateField, TimeField, + WfLookupField, + RadioField, } from './fields'; export interface DynamicFormProps { @@ -24,7 +26,17 @@ export interface DynamicFormProps { onCancel?: () => void; } -export function DynamicForm({ client, activityId, instanceId, onSuccess, onCancel }: DynamicFormProps) { +export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel }: DynamicFormProps) { + const [currentActivityId, setCurrentActivityId] = useState(initialActivityId); + const [currentInstanceId, setCurrentInstanceId] = useState(initialInstanceId); + const [chainQueue, setChainQueue] = useState>([]); + + useEffect(() => { + setCurrentActivityId(initialActivityId); + setCurrentInstanceId(initialInstanceId); + setChainQueue([]); + }, [initialActivityId, initialInstanceId]); + const [schema, setSchema] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -32,7 +44,7 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance useEffect(() => { let mounted = true; setLoading(true); - client.formSchema(activityId, instanceId) + client.formSchema(currentActivityId, currentInstanceId) .then(res => { if (mounted) { setSchema(res); @@ -55,10 +67,18 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance } }); } + const imageFieldIds = new Set( + res.fields.filter(f => f.data_type === 'image' || f.data_type === 'file').map(f => f.id) + ); + if (res.prefill_data) { - Object.assign(defaultValues, res.prefill_data); + Object.entries(res.prefill_data).forEach(([k, v]) => { + if (!imageFieldIds.has(k)) defaultValues[k] = v; + }); } else if (res.data) { - Object.assign(defaultValues, res.data); + Object.entries(res.data).forEach(([k, v]) => { + if (!imageFieldIds.has(k)) defaultValues[k] = v; + }); } setValues(defaultValues); setLoading(false); @@ -71,7 +91,7 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance } }); return () => { mounted = false; }; - }, [client, activityId]); + }, [client, currentActivityId, currentInstanceId]); const [values, setValues] = useState>({}); const [submitting, setSubmitting] = useState(false); @@ -108,24 +128,45 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance } else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) { const uploadedFiles = []; for (const file of val) { - const fileMeta = await client.uploadFile(file, { activityId, fieldId: f.id }); + const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id }); uploadedFiles.push(fileMeta); } payload[f.id] = uploadedFiles; } else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) { - const fileMeta = await client.uploadFile(val, { activityId, fieldId: f.id }); + const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id }); payload[f.id] = [fileMeta]; } else { payload[f.id] = val; } } - if (instanceId != null) { - await client.performActivity(instanceId, activityId, payload); + let res; + if (currentInstanceId != null) { + res = await client.performActivity(currentInstanceId, currentActivityId, payload); } else { - await client.startInstance(activityId, payload); + res = await client.startInstance(currentActivityId, payload); + } + + let pending = [...chainQueue]; + const newActivities = (schema.activity_chain || []).filter( + a => a.activity_uid !== currentActivityId + ); + + // Remove any existing occurrences from pending to avoid duplicates + pending = pending.filter(p => !newActivities.some(n => n.activity_uid === p.activity_uid)); + + // Prepend the new activities for depth-first execution (nested chaining) + pending = [...newActivities, ...pending]; + + const nextActivity = pending.shift(); + + if (nextActivity) { + setChainQueue(pending); + setCurrentActivityId(nextActivity.activity_uid); + setCurrentInstanceId(res.instance_id ?? currentInstanceId); + } else { + onSuccess?.(); } - onSuccess?.(); } catch (err: any) { setSubmitError(err.message || 'Failed to submit form'); } finally { @@ -141,6 +182,22 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance const isDisabled = schema.field_defaults?.[f.id]?.disabled; const renderField = () => { + if (type === 'wf_lookup') { + return ( + setValues(prev => ({ ...prev, [f.id]: newVal }))} + /> + ); + } + if (type.startsWith('select') || type.startsWith('multiselect')) { return ( setValues(prev => ({ ...prev, [f.id]: newVal }))} + /> + ); + } + if (type.startsWith('image') || type.startsWith('file')) { return ( void; +} + +export function RadioField({ label, required, value, options, onChange }: RadioFieldProps) { + // Fallback to Yes/No if no options provided + const opts = options?.length ? options : [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' } + ]; + + return ( +
+ +
+ {opts.map((opt) => ( + + ))} +
+
+ ); +} diff --git a/src/components/forms/fields/WfLookupField.tsx b/src/components/forms/fields/WfLookupField.tsx new file mode 100644 index 0000000..91db08b --- /dev/null +++ b/src/components/forms/fields/WfLookupField.tsx @@ -0,0 +1,80 @@ +import { useState, useEffect } from 'react'; +import type { ZinoClient } from '../../../api/client'; +import { SelectField } from './SelectField'; + +export interface WfLookupFieldProps { + label: string; + required?: boolean; + value: string | number; + onChange: (val: string) => void; + client: ZinoClient; + config: any; // wf_lookup_config + activityId: string; + fieldId: string; + formData: Record; +} + +export function WfLookupField({ label, required, value, onChange, client, config, activityId, fieldId, formData }: WfLookupFieldProps) { + const [options, setOptions] = useState<{ label: string; value: string }[]>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let mounted = true; + const wfUuid = config?.workflow_uuid; + if (!wfUuid) { + setLoading(false); + return; + } + + setLoading(true); + client.wfLookupRecords({ + activityId, + fieldId, + formData, + limit: 200 + }) + .then(res => { + if (!mounted) return; + // The API might return { data: [...] } or { records: [...] } or just an array + const arr = Array.isArray(res) ? res : (res.data || res.records || []); + + const displayFields = config.display_fields || []; + + const opts = arr.map((row: any) => { + // Join the display fields to form the label + const labelParts = displayFields + .map((df: any) => row[df.field_id]) + .filter((v: any) => v != null && v !== ''); + + const labelText = labelParts.length > 0 + ? labelParts.join(' - ') + : `ID: ${row.instance_id || row.id}`; + + return { + value: String(row.instance_id || row.id), + label: labelText + }; + }); + + setOptions(opts); + }) + .catch(err => { + console.error("Failed to load wf_lookup records:", err); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + + return () => { mounted = false; }; + }, [client, config]); + + return ( + + ); +} diff --git a/src/components/forms/fields/index.ts b/src/components/forms/fields/index.ts index cc0a903..37dd11a 100644 --- a/src/components/forms/fields/index.ts +++ b/src/components/forms/fields/index.ts @@ -8,3 +8,5 @@ export * from './EmailField'; export * from './TextAreaField'; export * from './DateField'; export * from './TimeField'; +export * from './WfLookupField'; +export * from './RadioField'; diff --git a/src/lib/format.ts b/src/lib/format.ts index 5a82092..c70a53b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -35,6 +35,10 @@ export function formatValue(value: unknown): string { if ('url' in o && o.url != null) return String(o.url); if ('name' in o && o.name != null) return String(o.name); if ('value' in o && o.value != null) return String(o.value); + // Custom workflow lookups + if ('business_name_2' in o && o.business_name_2 != null) return String(o.business_name_2); + if ('business_name_3' in o && o.business_name_3 != null) return String(o.business_name_3); + if ('business_name' in o && o.business_name != null) return String(o.business_name); } try { diff --git a/src/screens/OrdersPage.tsx b/src/screens/OrdersPage.tsx index 04b3336..1db86dd 100644 --- a/src/screens/OrdersPage.tsx +++ b/src/screens/OrdersPage.tsx @@ -38,7 +38,7 @@ export function OrdersPage() { > { setIsCreating(false); setRefreshKey(k => k + 1);