176 lines
5.2 KiB
TypeScript
176 lines
5.2 KiB
TypeScript
import React, { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useZino } from "./provider";
|
|
import type { FormField } from "./types";
|
|
|
|
export interface DynamicFormProps {
|
|
/** Workflow definition ID. */
|
|
workflowId: string;
|
|
/** Activity UID — the form schema is fetched for this activity. */
|
|
activityId: string;
|
|
/** Instance ID — omit for INIT activities (start workflow). */
|
|
instanceId?: string;
|
|
/** Called after successful submission with the result. */
|
|
onSuccess?: (result: unknown) => void;
|
|
/** Called on submission error. */
|
|
onError?: (error: unknown) => void;
|
|
/** Optional CSS class for the form wrapper. */
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Renders a dynamic form based on the activity's form schema.
|
|
*
|
|
* Fetches the form config from the core-service, renders fields by type,
|
|
* and submits via performActivity or startWorkflow.
|
|
*
|
|
* ```tsx
|
|
* <DynamicForm
|
|
* workflowId={WORKFLOW_ID}
|
|
* activityId={ACTIVITY_IDS.ASSIGN_TICKET}
|
|
* instanceId={instanceId}
|
|
* onSuccess={() => { closeModal(); }}
|
|
* />
|
|
* ```
|
|
*/
|
|
export function DynamicForm({
|
|
workflowId,
|
|
activityId,
|
|
instanceId,
|
|
onSuccess,
|
|
onError,
|
|
className,
|
|
}: DynamicFormProps) {
|
|
const { workflows } = useZino();
|
|
const queryClient = useQueryClient();
|
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
|
|
|
// Fetch form schema
|
|
const { data: form, isLoading: formLoading, error: formError } = useQuery({
|
|
queryKey: ["form", workflowId, activityId, instanceId],
|
|
queryFn: () => workflows.getForm(workflowId, activityId, "desktop", instanceId),
|
|
});
|
|
|
|
// Submit mutation
|
|
const submit = useMutation({
|
|
mutationFn: async (data: Record<string, string>) => {
|
|
if (instanceId) {
|
|
return workflows.performActivity(workflowId, instanceId, activityId, data);
|
|
} else {
|
|
return workflows.startWorkflow(workflowId, activityId, data);
|
|
}
|
|
},
|
|
onSuccess: (result) => {
|
|
queryClient.invalidateQueries({ queryKey: ["recordview"] });
|
|
queryClient.invalidateQueries({ queryKey: ["instance"] });
|
|
queryClient.invalidateQueries({ queryKey: ["detailview"] });
|
|
queryClient.invalidateQueries({ queryKey: ["audit"] });
|
|
onSuccess?.(result);
|
|
},
|
|
onError: (err) => {
|
|
onError?.(err);
|
|
},
|
|
});
|
|
|
|
const handleChange = (fieldId: string, value: string) => {
|
|
setFormData((prev) => ({ ...prev, [fieldId]: value }));
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
// Map form field IDs to their mapped_activity_field_id
|
|
const mapped: Record<string, string> = {};
|
|
for (const field of form?.form_json?.fields ?? []) {
|
|
const key = field.mapped_activity_field_id || field.id;
|
|
mapped[key] = formData[field.id] ?? "";
|
|
}
|
|
submit.mutate(mapped);
|
|
};
|
|
|
|
if (formLoading) {
|
|
return <div className="flex justify-center py-8"><div className="animate-spin h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full" /></div>;
|
|
}
|
|
|
|
if (formError) {
|
|
return <div className="text-red-600 text-sm py-4">Failed to load form: {(formError as any)?.message ?? "Unknown error"}</div>;
|
|
}
|
|
|
|
const fields: FormField[] = form?.form_json?.fields ?? [];
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className={className}>
|
|
{form?.form_json?.title && (
|
|
<h3 className="text-lg font-semibold mb-4">{form.form_json.title}</h3>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{fields.map((field) => (
|
|
<div key={field.id}>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{field.label}
|
|
</label>
|
|
{renderField(field, formData[field.id] ?? "", (v) => handleChange(field.id, v))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{submit.error && (
|
|
<div className="mt-3 text-sm text-red-600">
|
|
{(submit.error as any)?.message ?? "Submission failed"}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={submit.isPending}
|
|
className="mt-6 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
|
|
>
|
|
{submit.isPending ? "Submitting..." : "Submit"}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function renderField(
|
|
field: FormField,
|
|
value: string,
|
|
onChange: (value: string) => void,
|
|
) {
|
|
const baseClass =
|
|
"w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent";
|
|
|
|
switch (field.type) {
|
|
case "paragraph":
|
|
return (
|
|
<textarea
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
rows={4}
|
|
className={baseClass}
|
|
placeholder={field.label}
|
|
/>
|
|
);
|
|
case "number":
|
|
return (
|
|
<input
|
|
type="number"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={baseClass}
|
|
placeholder={field.label}
|
|
/>
|
|
);
|
|
case "text":
|
|
default:
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={baseClass}
|
|
placeholder={field.label}
|
|
/>
|
|
);
|
|
}
|
|
}
|