From 2e6fce188d23dfdf2aeba54f7387292bd0eac799 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 14 May 2026 11:15:16 +0200 Subject: [PATCH] fix: formular trigger --- .../WorkflowRuntimeFormFields.tsx | 236 ++++++++++++ .../GraphicalEditorWorkflowsTasksPage.tsx | 341 +++++++----------- 2 files changed, 363 insertions(+), 214 deletions(-) create mode 100644 src/components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields.tsx diff --git a/src/components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields.tsx b/src/components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields.tsx new file mode 100644 index 0000000..13da823 --- /dev/null +++ b/src/components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields.tsx @@ -0,0 +1,236 @@ +/** + * Runtime form fields — shared by Human Task input.form and workflow list trigger.form start. + * Field rows match task.config.fields / graph node parameters.formFields shape from the backend. + */ + +import React, { useEffect, useState } from 'react'; +import { useApiRequest } from '../../../hooks/useApi'; +import { loadClickupListTasksForDropdown, type ApiRequestFunction } from '../../../api/workflowApi'; +import { normalizeFormFieldOptions } from '../nodes/form'; +import { useLanguage } from '../../../providers/language/LanguageContext'; + +export type WorkflowRuntimeFormFieldRow = { + name: string; + type: string; + label: string; + required?: boolean; + options?: unknown; + clickupConnectionId?: string; + clickupListId?: string; + clickupStatusOptions?: Array<{ value: string; label: string }>; +}; + +export function relationshipTaskIdFromFormValue(v: unknown): string { + if (v && typeof v === 'object' && !Array.isArray(v) && 'add' in v) { + const a = (v as { add?: unknown[] }).add; + if (Array.isArray(a) && a[0] != null && String(a[0]).trim()) return String(a[0]); + } + return ''; +} + +function InputFormClickupTaskField({ + connectionId, + listId, + value, + onChange, + request, +}: { + connectionId: string; + listId: string; + value: unknown; + onChange: (v: unknown) => void; + request: ApiRequestFunction; +}) { + const { t } = useLanguage(); + const [tasks, setTasks] = useState>([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + + useEffect(() => { + const cid = connectionId.trim(); + const lid = listId.trim(); + if (!cid || !lid) { + setTasks([]); + return; + } + let cancelled = false; + setLoading(true); + setErr(null); + loadClickupListTasksForDropdown(request, cid, lid) + .then((rows) => { + if (!cancelled) setTasks(rows); + }) + .catch(() => { + if (!cancelled) { + setTasks([]); + setErr(t('Aufgaben konnten nicht geladen werden.')); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [request, connectionId, listId, t]); + + const sel = relationshipTaskIdFromFormValue(value); + + if (!connectionId.trim() || !listId.trim()) { + return ( +

+ {t('Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt — bitte Workflow prüfen.')} +

+ ); + } + + return ( + <> + {err ? ( +

{err}

+ ) : null} + {loading ? ( +

{t('lade Aufgaben')}

+ ) : ( + + )} + + ); +} + +export function useWorkflowRuntimeFormRequiredOk( + fields: WorkflowRuntimeFormFieldRow[], + formData: Record +): boolean { + const requiredFields = fields.filter((f) => f.required); + return requiredFields.every((f) => { + const v = formData[f.name]; + if (f.type === 'boolean') return true; + if (f.type === 'clickup_tasks') { + return relationshipTaskIdFromFormValue(v) !== ''; + } + if (f.type === 'clickup_status') { + return v !== undefined && v !== null && String(v).trim() !== ''; + } + if ( + (f.type === 'select' || f.type === 'enum') && + normalizeFormFieldOptions(f.options).some((o) => String(o.value).trim() !== '') + ) { + return v !== undefined && v !== null && String(v).trim() !== ''; + } + return v !== undefined && v !== null && String(v).trim() !== ''; + }); +} + +export interface WorkflowRuntimeFormFieldsProps { + fields: WorkflowRuntimeFormFieldRow[]; + formData: Record; + setFormData: React.Dispatch>>; + formFieldsClassName: string; +} + +/** + * Renders the same controls as TaskCard input.form (no Popup — parent wraps if needed). + */ +export const WorkflowRuntimeFormFields: React.FC = ({ + fields, + formData, + setFormData, + formFieldsClassName, +}) => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + + const renderFormControl = (field: WorkflowRuntimeFormFieldRow): React.ReactNode => { + const selectChoices = normalizeFormFieldOptions(field.options).filter((o) => String(o.value).trim() !== ''); + if (field.type === 'boolean') { + return ( + setFormData((p) => ({ ...p, [field.name]: e.target.checked }))} + /> + ); + } + if (field.type === 'clickup_tasks' && request) { + return ( + setFormData((p) => ({ ...p, [field.name]: v }))} + request={request} + /> + ); + } + if ( + field.type === 'clickup_status' && + Array.isArray(field.clickupStatusOptions) && + field.clickupStatusOptions.length > 0 + ) { + return ( + + ); + } + if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) { + return ( + + ); + } + return ( + setFormData((p) => ({ ...p, [field.name]: e.target.value }))} + /> + ); + }; + + return ( +
+ {fields.map((f) => ( +
+ + {renderFormControl(f)} +
+ ))} +
+ ); +}; diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx index d0a3279..554d564 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx @@ -17,18 +17,20 @@ import { fetchCompletedRuns, fetchWorkflows, executeGraph, - loadClickupListTasksForDropdown, type Automation2Task, type Automation2Workflow, type CompletedRun, - type ApiRequestFunction, } from '../../../api/workflowApi'; import { useToast } from '../../../contexts/ToastContext'; import { Popup } from '../../../components/UiComponents/Popup'; import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor'; import { useFileOperations } from '../../../hooks/useFiles'; import styles from './Automation2WorkflowsTasks.module.css'; -import { normalizeFormFieldOptions } from '../../../components/FlowEditor/nodes/form'; +import { + WorkflowRuntimeFormFields, + useWorkflowRuntimeFormRequiredOk, + type WorkflowRuntimeFormFieldRow, +} from '../../../components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields'; import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -77,17 +79,38 @@ function hasManualOrFormInvocation(wf: Automation2Workflow): boolean { } /** - * Primary entry for execute — POST /api/workflows/{instanceId}/execute with collected inputs. - * (manual first, then form or api). + * Primary entry for execute — align with first start node in graph order (backend-driven), + * then fall back to manual / form / api on invocations list. */ function getPrimaryEntryPoint(wf: Automation2Workflow) { const invs = wf.invocations || []; + const nodes = wf.graph?.nodes ?? []; + for (const n of nodes) { + const nodeType = n.type; + if (typeof nodeType === 'string' && nodeType.startsWith('trigger.')) { + const inv = invs.find((i) => i.enabled !== false && i.id === n.id); + if (inv) return inv; + } + } return ( invs.find((i) => i.enabled !== false && i.kind === 'manual') || invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api')) ); } +/** Form field rows from graph trigger.form for workflow list (parameters.formFields). */ +function getTriggerFormFieldsForWorkflow(wf: Automation2Workflow): WorkflowRuntimeFormFieldRow[] { + const primary = getPrimaryEntryPoint(wf); + if (!primary || primary.kind !== 'form') return []; + const nodes = wf.graph?.nodes ?? []; + let node = nodes.find((n) => n.id === primary.id && n.type === 'trigger.form'); + if (!node) node = nodes.find((n) => n.type === 'trigger.form'); + if (!node) return []; + const raw = (node.parameters as Record | undefined)?.formFields; + if (!Array.isArray(raw)) return []; + return raw as WorkflowRuntimeFormFieldRow[]; +} + function primaryKindLabel(kind: string): string { if (kind === 'form') return 'Formular'; if (kind === 'manual') return 'Manuell'; @@ -109,6 +132,9 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { const [submitting, setSubmitting] = useState(null); const [dismissingTaskId, setDismissingTaskId] = useState(null); const [executingWorkflowId, setExecutingWorkflowId] = useState(null); + const [formStartWorkflow, setFormStartWorkflow] = useState(null); + const [formStartFields, setFormStartFields] = useState([]); + const [startFormData, setStartFormData] = useState>({}); const load = useCallback(async () => { if (!instanceId) return; @@ -185,6 +211,12 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { async (wf: Automation2Workflow) => { if (!instanceId || !wf.graph) return; const primary = getPrimaryEntryPoint(wf); + if (primary?.kind === 'form') { + setFormStartFields(getTriggerFormFieldsForWorkflow(wf)); + setStartFormData({}); + setFormStartWorkflow(wf); + return; + } setExecutingWorkflowId(wf.id); try { const result = await executeGraph(request, instanceId, wf.graph, wf.id, { @@ -211,6 +243,48 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { [instanceId, request, showSuccess, showError, load, t] ); + const formStartRequiredOk = useWorkflowRuntimeFormRequiredOk(formStartFields, startFormData); + + const handleFormStartSubmit = useCallback(async () => { + if (!instanceId || !formStartWorkflow?.graph) return; + const wf = formStartWorkflow; + const primary = getPrimaryEntryPoint(wf); + const payload = { ...startFormData }; + setExecutingWorkflowId(wf.id); + try { + const result = await executeGraph(request, instanceId, wf.graph, wf.id, { + ...(primary ? { entryPointId: primary.id } : {}), + payload, + }); + if (result?.success) { + if (result?.paused) { + showSuccess(t('Workflow gestartet und bei Human Task pausiert.')); + } else { + showSuccess(t('Workflow gestartet')); + } + await load(); + } else { + showError(result?.error || t('Ausführung fehlgeschlagen')); + } + } catch (e: unknown) { + const msg = + (e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen'); + showError(msg); + } finally { + setExecutingWorkflowId(null); + setFormStartWorkflow(null); + } + }, [ + instanceId, + formStartWorkflow, + startFormData, + request, + showSuccess, + showError, + load, + t, + ]); + const openTasks = tasks.filter((task) => task.status === 'pending'); const completedTasks = tasks.filter((task) => task.status !== 'pending'); @@ -364,6 +438,41 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { )} + + setFormStartWorkflow(null)} + closable={ + !(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id) + } + closeOnEscape={ + !(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id) + } + size="medium" + footerContent={ + + } + > + + ); }; @@ -439,99 +548,6 @@ interface TaskCardProps { dismissing?: boolean; } -/** Check if file matches accept string (e.g. ".pdf,image/*"). */ -function relationshipTaskIdFromFormValue(v: unknown): string { - if (v && typeof v === 'object' && !Array.isArray(v) && 'add' in v) { - const a = (v as { add?: unknown[] }).add; - if (Array.isArray(a) && a[0] != null && String(a[0]).trim()) return String(a[0]); - } - return ''; -} - -function InputFormClickupTaskField({ - connectionId, - listId, - value, - onChange, - request, -}: { - connectionId: string; - listId: string; - value: unknown; - onChange: (v: unknown) => void; - request: ApiRequestFunction; -}) { - const { t } = useLanguage(); - const [tasks, setTasks] = useState>([]); - const [loading, setLoading] = useState(false); - const [err, setErr] = useState(null); - - useEffect(() => { - const cid = connectionId.trim(); - const lid = listId.trim(); - if (!cid || !lid) { - setTasks([]); - return; - } - let cancelled = false; - setLoading(true); - setErr(null); - loadClickupListTasksForDropdown(request, cid, lid) - .then((rows) => { - if (!cancelled) setTasks(rows); - }) - .catch(() => { - if (!cancelled) { - setTasks([]); - setErr(t('Aufgaben konnten nicht geladen werden.')); - } - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [request, connectionId, listId]); - - const sel = relationshipTaskIdFromFormValue(value); - - if (!connectionId.trim() || !listId.trim()) { - return ( -

- {t('Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt — bitte Workflow prüfen.')} -

- ); - } - - return ( - <> - {err ? ( -

{err}

- ) : null} - {loading ? ( -

{t('lade Aufgaben')}

- ) : ( - - )} - - ); -} - const TaskCard: React.FC = ({ task, instanceId, @@ -555,6 +571,12 @@ const TaskCard: React.FC = ({ const nodeType = task.nodeType; const stepLabel = getNodeStepLabel(config); + const inputFormFields: WorkflowRuntimeFormFieldRow[] = + nodeType === 'input.form' + ? ((config.fields as WorkflowRuntimeFormFieldRow[]) ?? []) + : []; + const inputFormRequiredOk = useWorkflowRuntimeFormRequiredOk(inputFormFields, formData); + useEffect(() => { setUploadedFiles([]); setUploadError(null); @@ -564,122 +586,13 @@ const TaskCard: React.FC = ({ if (readOnly) return null; switch (nodeType) { case 'input.form': { - const fields = - (config.fields as Array<{ - name: string; - type: string; - label: string; - required?: boolean; - options?: unknown; - clickupConnectionId?: string; - clickupListId?: string; - clickupStatusOptions?: Array<{ value: string; label: string }>; - }>) ?? []; - const requiredFields = fields.filter((f) => f.required); - const allRequiredFilled = requiredFields.every((f) => { - const v = formData[f.name]; - if (f.type === 'boolean') return true; - if (f.type === 'clickup_tasks') { - return relationshipTaskIdFromFormValue(v) !== ''; - } - if (f.type === 'clickup_status') { - return v !== undefined && v !== null && String(v).trim() !== ''; - } - if ((f.type === 'select' || f.type === 'enum') && normalizeFormFieldOptions(f.options).some((o) => String(o.value).trim() !== '')) { - return v !== undefined && v !== null && String(v).trim() !== ''; - } - return v !== undefined && v !== null && String(v).trim() !== ''; - }); - const renderFormControl = ( - field: (typeof fields)[number], - ): React.ReactNode => { - const selectChoices = normalizeFormFieldOptions(field.options).filter( - (o) => String(o.value).trim() !== '', - ); - if (field.type === 'boolean') { - return ( - - setFormData((p) => ({ ...p, [field.name]: e.target.checked })) - } - /> - ); - } - if (field.type === 'clickup_tasks' && request) { - return ( - setFormData((p) => ({ ...p, [field.name]: v }))} - request={request} - /> - ); - } - if ( - field.type === 'clickup_status' && - Array.isArray(field.clickupStatusOptions) && - field.clickupStatusOptions.length > 0 - ) { - return ( - - ); - } - if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) { - return ( - - ); - } - return ( - - setFormData((p) => ({ ...p, [field.name]: e.target.value })) - } - /> - ); - }; const formContent = ( -
- {fields.map((f) => ( -
- - {renderFormControl(f)} -
- ))} -
+ ); return ( <> @@ -704,7 +617,7 @@ const TaskCard: React.FC = ({ onSubmit({ payload: formData }); setFormPopupOpen(false); }} - disabled={submitting || !allRequiredFilled} + disabled={submitting || !inputFormRequiredOk} className={styles.popupSubmitButton} > {submitting ? t('wird gesendet') : t('absenden')}