450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
/**
|
||
* Inline dropdown to select a data source (node + path) - no popup.
|
||
* Form nodes (trigger.form / input.form): only payload.<fieldName> paths (no duplicate tree).
|
||
*/
|
||
|
||
import React from 'react';
|
||
import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
|
||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||
|
||
/** How to build path options for StatischKontextSelect / RefSourceSelect. */
|
||
export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
|
||
|
||
/** Only task IDs from ClickUp nodes — single path (taskId === clickupTask.id at runtime). */
|
||
function buildClickUpTaskIdPaths(): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||
return [{ path: ['taskId'], pathLabel: 'Aufgaben-ID' }];
|
||
}
|
||
|
||
/** Curated paths for clickup.* outputs — avoids huge documentData / payload trees. */
|
||
function buildClickUpOutputPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
|
||
{ path: ['taskId'], pathLabel: 'Aufgaben-ID' },
|
||
{ path: ['clickupTask', 'name'], pathLabel: 'clickupTask.name' },
|
||
{ path: ['success'], pathLabel: 'success' },
|
||
{ path: ['error'], pathLabel: 'error' },
|
||
{ path: ['documents', 0, 'documentName'], pathLabel: 'documents[0].documentName' },
|
||
];
|
||
if (preview && typeof preview === 'object') {
|
||
const p = preview as Record<string, unknown>;
|
||
const ct = p.clickupTask;
|
||
if (ct && typeof ct === 'object' && !Array.isArray(ct)) {
|
||
const o = ct as Record<string, unknown>;
|
||
for (const k of Object.keys(o)) {
|
||
if (k === 'id' || k === 'name') continue;
|
||
const v = o[k];
|
||
if (v != null && typeof v !== 'object') {
|
||
paths.push({ path: ['clickupTask', k], pathLabel: `clickupTask.${k}` });
|
||
}
|
||
if (k === 'status' && v && typeof v === 'object') {
|
||
paths.push({
|
||
path: ['clickupTask', 'status', 'status'],
|
||
pathLabel: 'clickupTask.status.status',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return paths;
|
||
}
|
||
|
||
function buildPickablePaths(
|
||
obj: unknown,
|
||
basePath: (string | number)[] = []
|
||
): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||
const pathLabel = basePath.length ? basePath.map(String).join('.') : '';
|
||
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
|
||
return [{ path: [...basePath], pathLabel }];
|
||
}
|
||
if (Array.isArray(obj)) {
|
||
const result: Array<{ path: (string | number)[]; pathLabel: string }> = [{ path: [...basePath], pathLabel }];
|
||
for (let i = 0; i < Math.min(obj.length, 10); i++) {
|
||
result.push(...buildPickablePaths(obj[i], [...basePath, i]));
|
||
}
|
||
return result;
|
||
}
|
||
if (typeof obj === 'object') {
|
||
const result: Array<{ path: (string | number)[]; pathLabel: string }> = [{ path: [...basePath], pathLabel }];
|
||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||
result.push(...buildPickablePaths(v, [...basePath, k]));
|
||
}
|
||
return result;
|
||
}
|
||
return [{ path: [...basePath], pathLabel }];
|
||
}
|
||
|
||
/** Nur Formular-Felder: ein Eintrag pro Feld unter payload.<name> — kein rekursives Durchwandern. */
|
||
function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
|
||
path: (string | number)[];
|
||
pathLabel: string;
|
||
}> {
|
||
const raw = params.formFields ?? params.fields;
|
||
if (!Array.isArray(raw)) return [];
|
||
const out: Array<{ path: (string | number)[]; pathLabel: string }> = [];
|
||
for (let i = 0; i < raw.length; i++) {
|
||
const row = raw[i];
|
||
if (!row || typeof row !== 'object') continue;
|
||
const name = String((row as Record<string, unknown>).name ?? `field${i + 1}`).trim();
|
||
if (!name) continue;
|
||
out.push({ path: ['payload', name], pathLabel: `payload.${name}` });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
|
||
{ path: ['currentItem'], pathLabel: 'currentItem' },
|
||
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
|
||
{ path: ['count'], pathLabel: 'count' },
|
||
];
|
||
if (preview && typeof preview === 'object') {
|
||
const ci = (preview as Record<string, unknown>).currentItem;
|
||
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
|
||
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
|
||
paths.push(...buildPickablePaths(v, ['currentItem', k]));
|
||
}
|
||
}
|
||
}
|
||
return paths;
|
||
}
|
||
|
||
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||
const paths = buildPickablePaths(preview);
|
||
if (preview && typeof preview === 'object') {
|
||
const rd = (preview as Record<string, unknown>).responseData;
|
||
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
|
||
for (const k of Object.keys(rd as Record<string, unknown>)) {
|
||
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
|
||
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
|
||
}
|
||
}
|
||
}
|
||
return paths;
|
||
}
|
||
|
||
export function pickPathsForNode(
|
||
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
|
||
preview: unknown,
|
||
mode: PathPickMode = 'default'
|
||
): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||
if (!node) return buildPickablePaths(preview);
|
||
const nt = node.type ?? '';
|
||
if (mode === 'clickup_task_id') {
|
||
if (nt.startsWith('clickup.')) {
|
||
return buildClickUpTaskIdPaths();
|
||
}
|
||
return [];
|
||
}
|
||
if (nt === 'trigger.form' || nt === 'input.form') {
|
||
return buildFormSchemaPayloadPaths(node.parameters ?? {});
|
||
}
|
||
if (node.type === 'input.upload') {
|
||
return buildPickablePathsForUpload();
|
||
}
|
||
if (nt.startsWith('clickup.')) {
|
||
return buildClickUpOutputPaths(preview);
|
||
}
|
||
if (nt === 'flow.loop') {
|
||
return buildLoopCurrentItemPaths(preview);
|
||
}
|
||
if (nt === 'ai.prompt') {
|
||
return buildAiPromptPaths(preview);
|
||
}
|
||
return buildPickablePaths(preview);
|
||
}
|
||
|
||
/** Für input.upload: nur relevante Pfade für If/Else – MIME-Type, Dateiname, Datei vorhanden. */
|
||
function buildPickablePathsForUpload(): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||
return [
|
||
{ path: [], pathLabel: '' },
|
||
{ path: ['file'], pathLabel: 'file' },
|
||
{ path: ['file', 'mimeType'], pathLabel: 'file.mimeType' },
|
||
{ path: ['file', 'fileName'], pathLabel: 'file.fileName' },
|
||
{ path: ['files'], pathLabel: 'files' },
|
||
{ path: ['fileIds'], pathLabel: 'fileIds' },
|
||
];
|
||
}
|
||
|
||
export function refToOptionValue(ref: DataRef): string {
|
||
return JSON.stringify(ref);
|
||
}
|
||
|
||
function _pathLabelForDisplay(pathLabel: string, translate: (key: string) => string): string {
|
||
if (pathLabel === 'Aufgaben-ID') return translate('Aufgaben-ID');
|
||
return pathLabel;
|
||
}
|
||
|
||
export function optionValueToRef(s: string): DataRef | null {
|
||
try {
|
||
const o = JSON.parse(s) as unknown;
|
||
if (o && typeof o === 'object' && (o as DataRef).type === 'ref' && typeof (o as DataRef).nodeId === 'string') {
|
||
return o as DataRef;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Option value for „Statisch (manuell)“ in StatischKontextSelect. */
|
||
export const STATIC_SOURCE_VALUE = '__static__';
|
||
|
||
function parseHybridLocal(value: unknown): { ref: DataRef | null; staticStr: string } {
|
||
if (isRef(value)) return { ref: value, staticStr: '' };
|
||
if (isValue(value)) {
|
||
const v = value.value;
|
||
if (v === null || v === undefined) return { ref: null, staticStr: '' };
|
||
return { ref: null, staticStr: String(v) };
|
||
}
|
||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||
return { ref: null, staticStr: String(value) };
|
||
}
|
||
return { ref: null, staticStr: '' };
|
||
}
|
||
|
||
/** Aktueller Wert des Quellen-Dropdowns: '' | STATIC_SOURCE_VALUE | ref-JSON. */
|
||
export function getStaticContextSelectValue(value: unknown): string {
|
||
if (isRef(value)) return refToOptionValue(value);
|
||
if (value === undefined || value === null) return '';
|
||
return STATIC_SOURCE_VALUE;
|
||
}
|
||
|
||
/** Statische Eingabe (Text, Checkbox, ClickUp-Option, …) nur bei „Statisch“ oder ohne vorgelagerte Nodes. */
|
||
export function shouldShowStaticControl(value: unknown, hasSources: boolean): boolean {
|
||
if (!hasSources) return true;
|
||
if (isRef(value)) return false;
|
||
return getStaticContextSelectValue(value) === STATIC_SOURCE_VALUE;
|
||
}
|
||
|
||
interface StatischKontextSelectProps {
|
||
value: unknown;
|
||
onChange: (v: unknown) => void;
|
||
placeholder?: string;
|
||
/** Label für die manuelle Option (Default: Statisch). */
|
||
staticLabel?: string;
|
||
/** default: full tree; clickup_task_id: only taskId from ClickUp nodes; exclude_forms: skip form nodes. */
|
||
pathPickMode?: PathPickMode;
|
||
}
|
||
|
||
/**
|
||
* Ein Dropdown: zuerst „Quelle wählen“, dann „Statisch“, dann Kontextpfade.
|
||
* Bei Kontext-Ref kein paralleles Textfeld (nur in HybridStaticRefField / ClickUp bei shouldShowStaticControl).
|
||
*/
|
||
export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
staticLabel,
|
||
pathPickMode = 'default',
|
||
}) => {
|
||
const { t } = useLanguage();
|
||
const dataFlow = useAutomation2DataFlow();
|
||
if (!dataFlow) return null;
|
||
|
||
const sourceIds = dataFlow.getAvailableSourceIds();
|
||
const options: Array<{ ref: DataRef; label: string }> = [];
|
||
|
||
for (const nodeId of sourceIds) {
|
||
const node = dataFlow.nodes.find((n) => n.id === nodeId);
|
||
if (node?.type === 'trigger.manual') continue;
|
||
if (
|
||
pathPickMode === 'exclude_forms' &&
|
||
(node?.type === 'input.form' || node?.type === 'trigger.form')
|
||
) {
|
||
continue;
|
||
}
|
||
const preview = dataFlow.nodeOutputsPreview[nodeId];
|
||
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
||
const paths = pickPathsForNode(node, preview, pathPickMode);
|
||
for (const p of paths) {
|
||
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
|
||
const displayLabel = pathLabelUi ? `${nodeLabel} → ${pathLabelUi}` : nodeLabel;
|
||
options.push({
|
||
ref: createRef(nodeId, p.path),
|
||
label: displayLabel,
|
||
});
|
||
}
|
||
}
|
||
|
||
const currentSelect = isRef(value)
|
||
? refToOptionValue(value)
|
||
: value === undefined || value === null
|
||
? ''
|
||
: STATIC_SOURCE_VALUE;
|
||
|
||
return (
|
||
<select
|
||
value={currentSelect}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
if (v === '') {
|
||
onChange(createValue(''));
|
||
return;
|
||
}
|
||
if (v === STATIC_SOURCE_VALUE) {
|
||
const { staticStr } = parseHybridLocal(value);
|
||
onChange(createValue(isRef(value) ? '' : staticStr));
|
||
return;
|
||
}
|
||
const ref = optionValueToRef(v);
|
||
if (ref) onChange(ref);
|
||
}}
|
||
>
|
||
<option value="">{placeholder ?? t('— Quelle wählen —')}</option>
|
||
<option value={STATIC_SOURCE_VALUE}>{staticLabel ?? t('Statisch')}</option>
|
||
{options.map((o) => (
|
||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
};
|
||
|
||
interface RefSourceSelectProps {
|
||
value: DataRef | null;
|
||
onChange: (ref: DataRef | null) => void;
|
||
placeholder?: string;
|
||
pathPickMode?: PathPickMode;
|
||
}
|
||
|
||
/** Nur Kontext-Referenzen (ohne Statisch) — für If/Else, Switch, DynamicValueField. */
|
||
export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
pathPickMode = 'default',
|
||
}) => {
|
||
const { t } = useLanguage();
|
||
const dataFlow = useAutomation2DataFlow();
|
||
if (!dataFlow) return null;
|
||
|
||
const sourceIds = dataFlow.getAvailableSourceIds();
|
||
const options: Array<{ ref: DataRef; label: string }> = [];
|
||
|
||
for (const nodeId of sourceIds) {
|
||
const node = dataFlow.nodes.find((n) => n.id === nodeId);
|
||
if (node?.type === 'trigger.manual') continue;
|
||
if (
|
||
pathPickMode === 'exclude_forms' &&
|
||
(node?.type === 'input.form' || node?.type === 'trigger.form')
|
||
) {
|
||
continue;
|
||
}
|
||
const preview = dataFlow.nodeOutputsPreview[nodeId];
|
||
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
||
const paths = pickPathsForNode(node, preview, pathPickMode);
|
||
for (const p of paths) {
|
||
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
|
||
const displayLabel = pathLabelUi ? `${nodeLabel} → ${pathLabelUi}` : nodeLabel;
|
||
options.push({
|
||
ref: createRef(nodeId, p.path),
|
||
label: displayLabel,
|
||
});
|
||
}
|
||
}
|
||
|
||
const currentValue = value ? refToOptionValue(value) : '';
|
||
|
||
return (
|
||
<select
|
||
value={currentValue}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
if (!v) {
|
||
onChange(null);
|
||
return;
|
||
}
|
||
const ref = optionValueToRef(v);
|
||
if (ref) onChange(ref);
|
||
}}
|
||
>
|
||
<option value="">{placeholder ?? t('Datenquelle wählen…')}</option>
|
||
{options.map((o) => (
|
||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
};
|
||
|
||
/** Inferred field type for operator selection and value input */
|
||
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'email' | 'file' | 'unknown';
|
||
|
||
function getFormFieldType(
|
||
node: { parameters?: Record<string, unknown>; type?: string },
|
||
path: (string | number)[]
|
||
): FieldType | null {
|
||
const params = node.parameters ?? {};
|
||
const raw = params.formFields ?? params.fields;
|
||
if (!Array.isArray(raw)) return null;
|
||
const isFormPayload =
|
||
(node.type === 'trigger.form' || node.type === 'input.form') && path[0] === 'payload';
|
||
const fieldName =
|
||
isFormPayload && path.length >= 2
|
||
? String(path[1])
|
||
: path.length >= 1
|
||
? String(path[0])
|
||
: null;
|
||
if (!fieldName) return null;
|
||
const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName);
|
||
if (!field || typeof field !== 'object') return null;
|
||
const rawFieldType = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
|
||
if (rawFieldType === 'number') return 'number';
|
||
if (rawFieldType === 'email') return 'email';
|
||
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
|
||
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
|
||
return 'string';
|
||
}
|
||
|
||
function getNodeOutputFieldType(
|
||
node: { type?: string },
|
||
path: (string | number)[]
|
||
): FieldType | null {
|
||
if (node.type === 'input.upload') {
|
||
if (path.length === 0 || (path.length === 1 && path[0] === 'file')) return 'file';
|
||
if (path[0] === 'file' && path[1] === 'mimeType') return 'string';
|
||
if (path[0] === 'file' && path[1] === 'fileName') return 'string';
|
||
if (path.length === 1 && (path[0] === 'files' || path[0] === 'fileIds')) return 'file';
|
||
}
|
||
if ((node.type?.startsWith('sharepoint.') || node.type?.startsWith('email.')) && path.includes('file')) {
|
||
const last = path[path.length - 1];
|
||
if (last === 'mimeType' || last === 'fileName') return 'string';
|
||
return 'file';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Infer field type from ref: form schema, node output shape, or preview value. */
|
||
export function getFieldType(
|
||
ref: DataRef | null,
|
||
nodes: Array<{ id: string; parameters?: Record<string, unknown>; type?: string }>,
|
||
nodeOutputsPreview: Record<string, unknown>
|
||
): FieldType {
|
||
if (!ref) return 'unknown';
|
||
const node = nodes.find((n) => n.id === ref.nodeId);
|
||
if (node) {
|
||
const fromForm = getFormFieldType(node, ref.path);
|
||
if (fromForm) return fromForm;
|
||
const fromNode = getNodeOutputFieldType(node, ref.path);
|
||
if (fromNode) return fromNode;
|
||
}
|
||
const root = nodeOutputsPreview[ref.nodeId];
|
||
if (root === undefined) return 'unknown';
|
||
let current: unknown = root;
|
||
for (const seg of ref.path) {
|
||
if (current == null) return 'unknown';
|
||
const key = typeof seg === 'number' ? String(seg) : seg;
|
||
if (Array.isArray(current) && /^\d+$/.test(key)) {
|
||
current = current[parseInt(key, 10)];
|
||
} else if (typeof current === 'object' && key in current) {
|
||
current = (current as Record<string, unknown>)[key];
|
||
} else return 'unknown';
|
||
}
|
||
if (typeof current === 'string') return 'string';
|
||
if (typeof current === 'number') return 'number';
|
||
if (typeof current === 'boolean') return 'boolean';
|
||
if (current && typeof current === 'object' && 'url' in (current as object)) return 'file';
|
||
return 'unknown';
|
||
}
|