fix: formular trigger

This commit is contained in:
Ida 2026-05-14 11:15:16 +02:00
parent 50a3df5c18
commit dd26ea132d
2 changed files with 363 additions and 214 deletions

View file

@ -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<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(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 (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
{t('Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt — bitte Workflow prüfen.')}
</p>
);
}
return (
<>
{err ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>{t('lade Aufgaben')}</p>
) : (
<select
value={sel}
onChange={(e) => {
const tid = e.target.value;
if (!tid) onChange({ add: [], rem: [] });
else onChange({ add: [tid], rem: [] });
}}
>
<option value="">{t('Aufgabe wählen')}</option>
{tasks.map((taskRow) => (
<option key={taskRow.id} value={taskRow.id}>
{taskRow.name}
</option>
))}
</select>
)}
</>
);
}
export function useWorkflowRuntimeFormRequiredOk(
fields: WorkflowRuntimeFormFieldRow[],
formData: Record<string, unknown>
): 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<string, unknown>;
setFormData: React.Dispatch<React.SetStateAction<Record<string, unknown>>>;
formFieldsClassName: string;
}
/**
* Renders the same controls as TaskCard input.form (no Popup parent wraps if needed).
*/
export const WorkflowRuntimeFormFields: React.FC<WorkflowRuntimeFormFieldsProps> = ({
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 (
<input
type="checkbox"
checked={(formData[field.name] as boolean) ?? false}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.checked }))}
/>
);
}
if (field.type === 'clickup_tasks' && request) {
return (
<InputFormClickupTaskField
connectionId={field.clickupConnectionId ?? ''}
listId={field.clickupListId ?? ''}
value={formData[field.name]}
onChange={(v) => setFormData((p) => ({ ...p, [field.name]: v }))}
request={request}
/>
);
}
if (
field.type === 'clickup_status' &&
Array.isArray(field.clickupStatusOptions) &&
field.clickupStatusOptions.length > 0
) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
>
<option value="">{t('Status wählen')}</option>
{field.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
>
<option value="">{t('Bitte wählen')}</option>
{selectChoices.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select>
);
}
return (
<input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
/>
);
};
return (
<div className={formFieldsClassName}>
{fields.map((f) => (
<div key={f.name}>
<label>
{f.label || f.name}
{f.required && ' *'}
</label>
{renderFormControl(f)}
</div>
))}
</div>
);
};

View file

@ -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<string, unknown> | 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<string | null>(null);
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [formStartWorkflow, setFormStartWorkflow] = useState<Automation2Workflow | null>(null);
const [formStartFields, setFormStartFields] = useState<WorkflowRuntimeFormFieldRow[]>([]);
const [startFormData, setStartFormData] = useState<Record<string, unknown>>({});
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 = () => {
)}
</div>
</aside>
<Popup
isOpen={formStartWorkflow != null}
title={t('Formular ausfüllen')}
onClose={() => setFormStartWorkflow(null)}
closable={
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
closeOnEscape={
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
size="medium"
footerContent={
<button
type="button"
onClick={() => void handleFormStartSubmit()}
disabled={
!formStartRequiredOk ||
(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
className={styles.popupSubmitButton}
>
{formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id
? t('wird gesendet')
: t('absenden')}
</button>
}
>
<WorkflowRuntimeFormFields
fields={formStartFields}
formData={startFormData}
setFormData={setStartFormData}
formFieldsClassName={styles.formFields}
/>
</Popup>
</div>
);
};
@ -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<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(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 (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
{t('Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt — bitte Workflow prüfen.')}
</p>
);
}
return (
<>
{err ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>{t('lade Aufgaben')}</p>
) : (
<select
value={sel}
onChange={(e) => {
const tid = e.target.value;
if (!tid) onChange({ add: [], rem: [] });
else onChange({ add: [tid], rem: [] });
}}
>
<option value="">{t('Aufgabe wählen')}</option>
{tasks.map((taskRow) => (
<option key={taskRow.id} value={taskRow.id}>
{taskRow.name}
</option>
))}
</select>
)}
</>
);
}
const TaskCard: React.FC<TaskCardProps> = ({
task,
instanceId,
@ -555,6 +571,12 @@ const TaskCard: React.FC<TaskCardProps> = ({
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<TaskCardProps> = ({
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 (
<input
type="checkbox"
checked={(formData[field.name] as boolean) ?? false}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.checked }))
}
/>
);
}
if (field.type === 'clickup_tasks' && request) {
return (
<InputFormClickupTaskField
connectionId={field.clickupConnectionId ?? ''}
listId={field.clickupListId ?? ''}
value={formData[field.name]}
onChange={(v) => setFormData((p) => ({ ...p, [field.name]: v }))}
request={request}
/>
);
}
if (
field.type === 'clickup_status' &&
Array.isArray(field.clickupStatusOptions) &&
field.clickupStatusOptions.length > 0
) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
>
<option value="">{t('Status wählen')}</option>
{field.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
>
<option value="">{t('Bitte wählen')}</option>
{selectChoices.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select>
);
}
return (
<input
type={
field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'
}
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
/>
);
};
const formContent = (
<div className={styles.formFields}>
{fields.map((f) => (
<div key={f.name}>
<label>
{f.label || f.name}
{f.required && ' *'}
</label>
{renderFormControl(f)}
</div>
))}
</div>
<WorkflowRuntimeFormFields
fields={inputFormFields}
formData={formData}
setFormData={setFormData}
formFieldsClassName={styles.formFields}
/>
);
return (
<>
@ -704,7 +617,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
onSubmit({ payload: formData });
setFormPopupOpen(false);
}}
disabled={submitting || !allRequiredFilled}
disabled={submitting || !inputFormRequiredOk}
className={styles.popupSubmitButton}
>
{submitting ? t('wird gesendet') : t('absenden')}