fix:workflow lookupfield and radiofield added made activity chain implementation

This commit is contained in:
suryac 2026-07-07 15:18:33 +05:30
parent 4410a38353
commit 94fbcc16ee
8 changed files with 235 additions and 12 deletions

View File

@ -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<string, unknown>;
search?: string;
limit?: number;
offset?: number;
}
): Promise<{ data: Array<Record<string, unknown>>; records?: Array<Record<string, unknown>> } | Array<Record<string, unknown>>> {
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

View File

@ -153,6 +153,12 @@ export interface FormScreenResponse {
data?: Record<string, unknown>;
/** Existing instance data is sometimes returned as prefill_data. */
prefill_data?: Record<string, unknown>;
/** 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) ---

View File

@ -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<number | string | undefined>(initialInstanceId);
const [chainQueue, setChainQueue] = useState<Array<{ activity_uid: string; activity_name: string }>>([]);
useEffect(() => {
setCurrentActivityId(initialActivityId);
setCurrentInstanceId(initialInstanceId);
setChainQueue([]);
}, [initialActivityId, initialInstanceId]);
const [schema, setSchema] = useState<FormScreenResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Record<string, unknown>>({});
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 (
<WfLookupField
label={f.name}
required={f.mandatory}
value={(val as string | number) ?? ''}
client={client}
config={f.properties?.wf_lookup_config}
activityId={currentActivityId}
fieldId={f.id}
formData={values}
onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
/>
);
}
if (type.startsWith('select') || type.startsWith('multiselect')) {
return (
<SelectField
@ -153,6 +210,18 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance
);
}
if (type === 'radio') {
return (
<RadioField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
options={f.properties?.options || []}
onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
/>
);
}
if (type.startsWith('image') || type.startsWith('file')) {
return (
<FileInput

View File

@ -0,0 +1,38 @@
export interface RadioFieldProps {
label: string;
required?: boolean;
value: string;
options: { label: string; value: string }[];
onChange: (val: string) => 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 (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-semibold text-strong">
{label}
{required && <span className="text-ruby-600 ml-1">*</span>}
</label>
<div className="flex flex-wrap gap-4 mt-1">
{opts.map((opt) => (
<label key={String(opt.value)} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
value={String(opt.value)}
checked={String(value) === String(opt.value)}
onChange={(e) => onChange(e.target.value)}
className="w-4 h-4 text-primary bg-card border-border-default focus:ring-1 focus:ring-primary focus:outline-none cursor-pointer"
/>
<span className="text-sm text-body">{opt.label}</span>
</label>
))}
</div>
</div>
);
}

View File

@ -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<string, unknown>;
}
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 (
<SelectField
label={loading ? `${label} (Loading...)` : label}
required={required}
value={String(value || '')}
onChange={onChange}
options={options}
/>
);
}

View File

@ -8,3 +8,5 @@ export * from './EmailField';
export * from './TextAreaField';
export * from './DateField';
export * from './TimeField';
export * from './WfLookupField';
export * from './RadioField';

View File

@ -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 {

View File

@ -38,7 +38,7 @@ export function OrdersPage() {
>
<DynamicForm
client={orderBookingClient}
activityId={ORDER_BOOKING.activities.PLACE_ORDER.uid}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);