fix:workflow lookupfield and radiofield added made activity chain implementation
This commit is contained in:
parent
4410a38353
commit
94fbcc16ee
@ -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.) ---
|
// --- Dataset-backed select options (state/city cascade etc.) ---
|
||||||
|
|
||||||
/** Fetch options for a dataset-backed select. The server resolves the field's
|
/** Fetch options for a dataset-backed select. The server resolves the field's
|
||||||
|
|||||||
@ -153,6 +153,12 @@ export interface FormScreenResponse {
|
|||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
/** Existing instance data is sometimes returned as prefill_data. */
|
/** Existing instance data is sometimes returned as prefill_data. */
|
||||||
prefill_data?: Record<string, unknown>;
|
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) ---
|
// --- Audit (GET /app/{appId}/view/audit) ---
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
TextAreaField,
|
TextAreaField,
|
||||||
DateField,
|
DateField,
|
||||||
TimeField,
|
TimeField,
|
||||||
|
WfLookupField,
|
||||||
|
RadioField,
|
||||||
} from './fields';
|
} from './fields';
|
||||||
|
|
||||||
export interface DynamicFormProps {
|
export interface DynamicFormProps {
|
||||||
@ -24,7 +26,17 @@ export interface DynamicFormProps {
|
|||||||
onCancel?: () => void;
|
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 [schema, setSchema] = useState<FormScreenResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -32,7 +44,7 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
client.formSchema(activityId, instanceId)
|
client.formSchema(currentActivityId, currentInstanceId)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setSchema(res);
|
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) {
|
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) {
|
} 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);
|
setValues(defaultValues);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -71,7 +91,7 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [client, activityId]);
|
}, [client, currentActivityId, currentInstanceId]);
|
||||||
|
|
||||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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) {
|
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) {
|
||||||
const uploadedFiles = [];
|
const uploadedFiles = [];
|
||||||
for (const file of val) {
|
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);
|
uploadedFiles.push(fileMeta);
|
||||||
}
|
}
|
||||||
payload[f.id] = uploadedFiles;
|
payload[f.id] = uploadedFiles;
|
||||||
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
|
} 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];
|
payload[f.id] = [fileMeta];
|
||||||
} else {
|
} else {
|
||||||
payload[f.id] = val;
|
payload[f.id] = val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instanceId != null) {
|
let res;
|
||||||
await client.performActivity(instanceId, activityId, payload);
|
if (currentInstanceId != null) {
|
||||||
|
res = await client.performActivity(currentInstanceId, currentActivityId, payload);
|
||||||
} else {
|
} 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) {
|
} catch (err: any) {
|
||||||
setSubmitError(err.message || 'Failed to submit form');
|
setSubmitError(err.message || 'Failed to submit form');
|
||||||
} finally {
|
} finally {
|
||||||
@ -141,6 +182,22 @@ export function DynamicForm({ client, activityId, instanceId, onSuccess, onCance
|
|||||||
const isDisabled = schema.field_defaults?.[f.id]?.disabled;
|
const isDisabled = schema.field_defaults?.[f.id]?.disabled;
|
||||||
|
|
||||||
const renderField = () => {
|
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')) {
|
if (type.startsWith('select') || type.startsWith('multiselect')) {
|
||||||
return (
|
return (
|
||||||
<SelectField
|
<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')) {
|
if (type.startsWith('image') || type.startsWith('file')) {
|
||||||
return (
|
return (
|
||||||
<FileInput
|
<FileInput
|
||||||
|
|||||||
38
src/components/forms/fields/RadioField.tsx
Normal file
38
src/components/forms/fields/RadioField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/forms/fields/WfLookupField.tsx
Normal file
80
src/components/forms/fields/WfLookupField.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,3 +8,5 @@ export * from './EmailField';
|
|||||||
export * from './TextAreaField';
|
export * from './TextAreaField';
|
||||||
export * from './DateField';
|
export * from './DateField';
|
||||||
export * from './TimeField';
|
export * from './TimeField';
|
||||||
|
export * from './WfLookupField';
|
||||||
|
export * from './RadioField';
|
||||||
|
|||||||
@ -35,6 +35,10 @@ export function formatValue(value: unknown): string {
|
|||||||
if ('url' in o && o.url != null) return String(o.url);
|
if ('url' in o && o.url != null) return String(o.url);
|
||||||
if ('name' in o && o.name != null) return String(o.name);
|
if ('name' in o && o.name != null) return String(o.name);
|
||||||
if ('value' in o && o.value != null) return String(o.value);
|
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 {
|
try {
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export function OrdersPage() {
|
|||||||
>
|
>
|
||||||
<DynamicForm
|
<DynamicForm
|
||||||
client={orderBookingClient}
|
client={orderBookingClient}
|
||||||
activityId={ORDER_BOOKING.activities.PLACE_ORDER.uid}
|
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setRefreshKey(k => k + 1);
|
setRefreshKey(k => k + 1);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user