finished email nodes

This commit is contained in:
idittrich-valueon 2026-03-22 18:22:06 +01:00
parent 835428ac5f
commit af58d5a868
12 changed files with 631 additions and 115 deletions

View file

@ -289,3 +289,68 @@ export async function completeTask(
data: { result }, data: { result },
}); });
} }
// -------------------------------------------------------------------------
// Connections and Browse (for Email/SharePoint node config)
// -------------------------------------------------------------------------
export interface UserConnection {
id: string;
authority: string;
externalUsername?: string;
externalEmail?: string;
status: string;
}
export async function fetchConnections(
request: ApiRequestFunction,
instanceId: string
): Promise<UserConnection[]> {
const data = await request({
url: `/api/automation2/${instanceId}/connections`,
method: 'get',
});
return data?.connections ?? [];
}
export interface ConnectionService {
service: string;
label: string;
icon: string;
}
export async function fetchConnectionServices(
request: ApiRequestFunction,
instanceId: string,
connectionId: string
): Promise<ConnectionService[]> {
const data = await request({
url: `/api/automation2/${instanceId}/connections/${connectionId}/services`,
method: 'get',
});
return data?.services ?? [];
}
export interface BrowseEntry {
name: string;
path: string;
isFolder: boolean;
size?: number;
mimeType?: string;
metadata?: Record<string, unknown>;
}
export async function fetchBrowse(
request: ApiRequestFunction,
instanceId: string,
connectionId: string,
service: string,
path = '/'
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
const data = await request({
url: `/api/automation2/${instanceId}/connections/${connectionId}/browse`,
method: 'get',
params: { service, path },
});
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
}

View file

@ -50,9 +50,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>( const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data']) new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint'])
); );
const [expandedIoMethods, setExpandedIoMethods] = useState<Set<string>>(new Set());
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]); const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]); const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
@ -208,15 +207,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
}); });
}, []); }, []);
const toggleIoMethod = useCallback((method: string) => {
setExpandedIoMethods((prev) => {
const next = new Set(prev);
if (next.has(method)) next.delete(method);
else next.add(method);
return next;
});
}, []);
const handleDropNodeType = useCallback( const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => { (nodeTypeId: string, x: number, y: number) => {
const nt = nodeTypes.find((n) => n.id === nodeTypeId); const nt = nodeTypes.find((n) => n.id === nodeTypeId);
@ -280,9 +270,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onFilterChange={setFilter} onFilterChange={setFilter}
language={language} language={language}
expandedCategories={expandedCategories} expandedCategories={expandedCategories}
expandedIoMethods={expandedIoMethods}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
onToggleIoMethod={toggleIoMethod}
/> />
); );
}; };
@ -318,12 +306,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onSelectionChange={setSelectedNode} onSelectionChange={setSelectedNode}
/> />
</div> </div>
{selectedNode?.type?.startsWith('input.') && ( {selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.'].some((p) =>
selectedNode.type.startsWith(p)
) && (
<NodeConfigPanel <NodeConfigPanel
node={selectedNode} node={selectedNode}
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)} nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
language={language} language={language}
onParametersChange={handleNodeParametersChange} onParametersChange={handleNodeParametersChange}
instanceId={instanceId}
request={request}
/> />
)} )}
</div> </div>

View file

@ -1,11 +1,12 @@
/** /**
* NodeConfigPanel - Configures parameters for input/human nodes. * NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
* Delegates to config components from configs/. * Delegates to config components from configs/.
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import type { CanvasNode } from './FlowCanvas'; import type { CanvasNode } from './FlowCanvas';
import type { NodeType } from '../../api/automation2Api'; import type { NodeType } from '../../api/automation2Api';
import type { ApiRequestFunction } from '../../api/automation2Api';
import { getLabel } from './utils'; import { getLabel } from './utils';
import { NODE_CONFIG_REGISTRY } from './configs'; import { NODE_CONFIG_REGISTRY } from './configs';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
@ -15,13 +16,19 @@ interface NodeConfigPanelProps {
nodeType: NodeType | undefined; nodeType: NodeType | undefined;
language: string; language: string;
onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void; onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void;
instanceId?: string;
request?: ApiRequestFunction;
} }
const CONFIGURABLE_PREFIXES = ['input.', 'ai.', 'email.', 'sharepoint.'];
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
node, node,
nodeType, nodeType,
language, language,
onParametersChange, onParametersChange,
instanceId,
request,
}) => { }) => {
const [params, setParams] = useState<Record<string, unknown>>({}); const [params, setParams] = useState<Record<string, unknown>>({});
@ -35,14 +42,15 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
if (node) onParametersChange(node.id, next); if (node) onParametersChange(node.id, next);
}; };
if (!node || !node.type.startsWith('input.')) return null; const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
if (!node || !isConfigurable) return null;
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type]; const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
if (!ConfigRenderer) { if (!ConfigRenderer) {
return ( return (
<div className={styles.nodeConfigPanel}> <div className={styles.nodeConfigPanel}>
<h4>{getLabel(nodeType?.label, language) || node.type}</h4> <h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<p>Keine Konfiguration r {node.type}</p> <p>No configuration for {node.type}</p>
</div> </div>
); );
} }
@ -50,7 +58,13 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
return ( return (
<div className={styles.nodeConfigPanel}> <div className={styles.nodeConfigPanel}>
<h4>{getLabel(nodeType?.label, language) || node.type}</h4> <h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<ConfigRenderer params={params} updateParam={updateParam} /> <ConfigRenderer
params={params}
updateParam={updateParam}
instanceId={instanceId}
request={request}
nodeType={node.type}
/>
</div> </div>
); );
}; };

View file

@ -1,13 +1,13 @@
/** /**
* NodeSidebar - Sidebar with searchable, collapsible node list. * NodeSidebar - Sidebar with searchable, collapsible node list.
* Groups node types by category; I/O nodes are sub-grouped by method. * Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa'; import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import type { NodeType, NodeTypeCategory } from '../../api/automation2Api'; import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
import { IO_METHOD_ORDER, CATEGORY_ORDER } from './constants'; import { CATEGORY_ORDER } from './constants';
import { getLabel, getIoMethodLabel } from './utils'; import { getLabel } from './utils';
import { NodeListItem } from './NodeListItem'; import { NodeListItem } from './NodeListItem';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
@ -18,9 +18,7 @@ interface NodeSidebarProps {
onFilterChange: (value: string) => void; onFilterChange: (value: string) => void;
language: string; language: string;
expandedCategories: Set<string>; expandedCategories: Set<string>;
expandedIoMethods: Set<string>;
onToggleCategory: (id: string) => void; onToggleCategory: (id: string) => void;
onToggleIoMethod: (method: string) => void;
} }
export const NodeSidebar: React.FC<NodeSidebarProps> = ({ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
@ -30,9 +28,7 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
onFilterChange, onFilterChange,
language, language,
expandedCategories, expandedCategories,
expandedIoMethods,
onToggleCategory, onToggleCategory,
onToggleIoMethod,
}) => { }) => {
const filteredNodeTypes = useMemo(() => { const filteredNodeTypes = useMemo(() => {
if (!filter.trim()) return nodeTypes; if (!filter.trim()) return nodeTypes;
@ -55,25 +51,6 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
return map; return map;
}, [filteredNodeTypes]); }, [filteredNodeTypes]);
const ioSubGroups = useMemo(() => {
const ioNodes = groupedByCategory['io'] || [];
const byMethod: Record<string, NodeType[]> = {};
ioNodes.forEach((n) => {
const method = n.meta?.method ?? n.id.split('.')[1] ?? 'other';
if (!byMethod[method]) byMethod[method] = [];
byMethod[method].push(n);
});
const ordered: Array<{ method: string; nodes: NodeType[] }> = [];
const methodOrder = [...IO_METHOD_ORDER];
methodOrder.forEach((m) => {
if (byMethod[m]?.length) ordered.push({ method: m, nodes: byMethod[m] });
});
Object.keys(byMethod).forEach((m) => {
if (!methodOrder.includes(m)) ordered.push({ method: m, nodes: byMethod[m] });
});
return ordered;
}, [groupedByCategory]);
const orderedCategories = useMemo(() => { const orderedCategories = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
const result: string[] = []; const result: string[] = [];
@ -109,44 +86,6 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
const isExpanded = expandedCategories.has(catId); const isExpanded = expandedCategories.has(catId);
const catLabel = categories.find((c) => c.id === catId); const catLabel = categories.find((c) => c.id === catId);
const label = getLabel(catLabel?.label, language) || catId; const label = getLabel(catLabel?.label, language) || catId;
if (catId === 'io' && ioSubGroups.length > 0) {
return (
<React.Fragment key={catId}>
{ioSubGroups.map(({ method, nodes }) => {
const methodLabel = getIoMethodLabel(method, language);
const isMethodExpanded = expandedIoMethods.has(method);
return (
<div key={`io-${method}`} className={styles.categoryGroup}>
<button
type="button"
className={styles.categoryHeader}
onClick={() => onToggleIoMethod(method)}
>
{isMethodExpanded ? (
<FaChevronDown className={styles.categoryIcon} />
) : (
<FaChevronRight className={styles.categoryIcon} />
)}
<span className={styles.categoryLabel}>{methodLabel}</span>
<span className={styles.categoryCount}>{nodes.length}</span>
</button>
{isMethodExpanded &&
nodes.map((node) => (
<NodeListItem
key={node.id}
node={node}
language={language}
getLabel={getLabelFn}
/>
))}
</div>
);
})}
</React.Fragment>
);
}
const items = groupedByCategory[catId] || []; const items = groupedByCategory[catId] || [];
return ( return (
<div key={catId} className={styles.categoryGroup}> <div key={catId} className={styles.categoryGroup}>

View file

@ -3,14 +3,16 @@
*/ */
import React from 'react'; import React from 'react';
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser } from 'react-icons/fa'; import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud } from 'react-icons/fa';
export const CATEGORY_ICONS: Record<string, React.ReactNode> = { export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
trigger: <FaPlay />, trigger: <FaPlay />,
input: <FaUser />, input: <FaUser />,
flow: <FaCodeBranch />, flow: <FaCodeBranch />,
data: <FaDatabase />, data: <FaDatabase />,
io: <FaPlug />, ai: <FaRobot />,
email: <FaEnvelope />,
sharepoint: <FaCloud />,
human: <FaUser />, human: <FaUser />,
}; };

View file

@ -0,0 +1,68 @@
/**
* AI node config - prompt, query, document options per node type.
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
const AI_FIELD_CONFIG: Record<string, { label: string; key: string; type: 'textarea' | 'input' | 'select'; options?: string[] }[]> = {
'ai.prompt': [
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
{ label: 'Output format', key: 'resultType', type: 'select', options: ['txt', 'json', 'md', 'html', 'csv'] },
],
'ai.webResearch': [{ label: 'Query', key: 'query', type: 'textarea' }],
'ai.summarizeDocument': [
{ label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] },
],
'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }],
'ai.convertDocument': [
{ label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] },
],
'ai.generateDocument': [
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
{ label: 'Format', key: 'format', type: 'select', options: ['docx', 'txt', 'md'] },
],
'ai.generateCode': [
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
{ label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] },
],
};
export const AiNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam, nodeType = 'ai.prompt' }) => {
const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt'];
return (
<>
{fields.map((f) => (
<div key={f.key}>
<label>{f.label}</label>
{f.type === 'textarea' ? (
<textarea
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
rows={4}
/>
) : f.type === 'select' ? (
<select
value={(params[f.key] as string) ?? (f.options?.[0] ?? '')}
onChange={(e) => updateParam(f.key, e.target.value)}
>
{(f.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
/>
)}
</div>
))}
</>
);
};

View file

@ -0,0 +1,240 @@
/**
* Email node config - connection selector, folder dropdown, query, subject, body.
*/
import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
nodeType = 'email.checkEmail',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
const [folders, setFolders] = useState<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
const [foldersLoading, setFoldersLoading] = useState(false);
useEffect(() => {
if (instanceId && request) {
setLoading(true);
fetchConnections(request, instanceId)
.then(setConnections)
.catch(() => setConnections([]))
.finally(() => setLoading(false));
}
}, [instanceId, request]);
const connectionId = (params.connectionId as string) ?? '';
const selectedConn = connections.find((c) => c.id === connectionId);
const mailService = selectedConn?.authority === 'google' ? 'gmail' : 'outlook';
useEffect(() => {
if (instanceId && request && connectionId) {
setFoldersLoading(true);
fetchBrowse(request, instanceId, connectionId, mailService, '/')
.then((r) => setFolders(r.items.filter((e) => e.isFolder)))
.catch(() => setFolders([]))
.finally(() => setFoldersLoading(false));
} else {
setFolders([]);
}
}, [instanceId, request, connectionId, mailService]);
const isDraft = nodeType === 'email.draftEmail';
const isSearch = nodeType === 'email.searchEmail';
const folderValue = (params.folder as string) ?? (isSearch ? 'All' : 'Inbox');
return (
<>
<div>
<label>Account</label>
<select
value={connectionId}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={loading}
>
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalEmail ?? c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{!isDraft && (
<div>
<label>Folder</label>
<select
value={folderValue}
onChange={(e) => updateParam('folder', e.target.value)}
disabled={foldersLoading || !connectionId}
>
<option value="">
{foldersLoading ? 'Loading folders...' : !connectionId ? 'Select account first' : 'Select folder'}
</option>
{isSearch && <option value="All">All</option>}
{folders.length > 0
? folders.map((f) => {
const folderId = (f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id || '';
const value = folderId || f.name;
if (!value) return null;
return (
<option key={value} value={value}>
{f.name}
</option>
);
})
: !isSearch && (
<>
<option value="Inbox">Inbox</option>
<option value="Drafts">Drafts</option>
<option value="SentItems">Sent Items</option>
<option value="DeletedItems">Deleted Items</option>
<option value="JunkEmail">Junk Email</option>
</>
)}
{folderValue &&
!folders.some(
(f) =>
((f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id) === folderValue
) &&
folderValue !== 'All' && (
<option value={folderValue}>{folderValue}</option>
)}
</select>
</div>
)}
{isSearch && (
<>
<div>
<label>Search query (optional)</label>
<input
value={(params.query as string) ?? ''}
onChange={(e) => updateParam('query', e.target.value)}
placeholder="General search term (subject, body, from)"
/>
</div>
<div>
<label>From address (optional)</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder="e.g. sender@example.com"
/>
</div>
<div>
<label>To address (optional)</label>
<input
value={(params.toAddress as string) ?? ''}
onChange={(e) => updateParam('toAddress', e.target.value)}
placeholder="e.g. recipient@example.com"
/>
</div>
<div>
<label>Subject contains (optional)</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder="Word or phrase in subject"
/>
</div>
<div>
<label>Body/content contains (optional)</label>
<input
value={(params.bodyContains as string) ?? ''}
onChange={(e) => updateParam('bodyContains', e.target.value)}
placeholder="Word or phrase in email body"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id="searchHasAttachment"
checked={!!(params.hasAttachment as boolean)}
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
/>
<label htmlFor="searchHasAttachment">Only emails with attachment</label>
</div>
<div>
<label>Limit</label>
<input
type="number"
value={(params.limit as number) ?? 100}
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
/>
</div>
</>
)}
{nodeType === 'email.checkEmail' && (
<>
<div>
<label>From address (optional)</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder="e.g. sender@example.com"
/>
</div>
<div>
<label>Subject contains (optional)</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder="Word or phrase in subject"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id="hasAttachment"
checked={!!(params.hasAttachment as boolean)}
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
/>
<label htmlFor="hasAttachment">Only emails with attachment</label>
</div>
<div>
<label>Limit</label>
<input
type="number"
value={(params.limit as number) ?? 100}
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
/>
</div>
</>
)}
{isDraft && (
<>
<div>
<label>Subject</label>
<input
value={(params.subject as string) ?? ''}
onChange={(e) => updateParam('subject', e.target.value)}
placeholder="Email subject (or leave empty if connected to AI node above)"
/>
</div>
<div>
<label>Body</label>
<textarea
value={(params.body as string) ?? ''}
onChange={(e) => updateParam('body', e.target.value)}
placeholder="Email body (or leave empty if connected to AI node above)"
rows={4}
/>
</div>
<div>
<label>To (optional)</label>
<input
value={(params.to as string) ?? ''}
onChange={(e) => updateParam('to', e.target.value)}
placeholder="Recipient(s) (or from AI when connected)"
/>
</div>
</>
)}
</>
);
};

View file

@ -0,0 +1,190 @@
/**
* SharePoint node config - connection selector, path, search query.
*/
import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
nodeType = 'sharepoint.findFile',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
const [browseItems, setBrowseItems] = useState<BrowseEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [loading, setLoading] = useState(false);
const connectionId = (params.connectionId as string) ?? '';
const path = (params.path as string) ?? '/';
useEffect(() => {
if (instanceId && request) {
setLoading(true);
fetchConnections(request, instanceId)
.then(setConnections)
.catch(() => setConnections([]))
.finally(() => setLoading(false));
}
}, [instanceId, request]);
useEffect(() => {
if (instanceId && request && connectionId) {
const service = 'sharepoint';
fetchBrowse(request, instanceId, connectionId, service, currentPath)
.then((r: { items: BrowseEntry[]; path: string; service: string }) => {
setBrowseItems(r.items);
setCurrentPath(r.path);
})
.catch(() => setBrowseItems([]));
} else {
setBrowseItems([]);
}
}, [instanceId, request, connectionId, currentPath]);
const navigateTo = (entryPath: string) => setCurrentPath(entryPath);
const selectPath = (p: string) => updateParam('path', p);
const needsPath = !['sharepoint.findFile'].includes(nodeType);
const needsSearch = nodeType === 'sharepoint.findFile';
const needsSiteId = ['sharepoint.uploadFile', 'sharepoint.downloadFile', 'sharepoint.copyFile'].includes(nodeType);
return (
<>
<div>
<label>Connection</label>
<select
value={connectionId}
onChange={(e) => {
updateParam('connectionId', e.target.value);
setCurrentPath('/');
}}
disabled={loading}
>
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{needsSearch && (
<div>
<label>Search query / path</label>
<input
value={(params.searchQuery as string) ?? ''}
onChange={(e) => updateParam('searchQuery', e.target.value)}
placeholder="/sites/SiteName/Shared Documents or search term"
/>
</div>
)}
{needsPath && nodeType === 'sharepoint.listFiles' && (
<div>
<label>Folder path</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
placeholder="/ or /sites/SiteName/Shared Documents/Folder"
/>
</div>
)}
{needsPath && ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile'].includes(nodeType) && (
<div>
<label>Path</label>
<input
value={(params.path as string) ?? (params.filePath as string) ?? ''}
onChange={(e) =>
updateParam(nodeType === 'sharepoint.downloadFile' ? 'filePath' : 'path', e.target.value)
}
placeholder="File or folder path"
/>
</div>
)}
{needsSiteId && (
<div>
<label>Site ID</label>
<input
value={(params.siteId as string) ?? ''}
onChange={(e) => updateParam('siteId', e.target.value)}
placeholder="SharePoint site ID"
/>
</div>
)}
{nodeType === 'sharepoint.uploadFile' && (
<div>
<label>File name</label>
<input
value={(params.fileName as string) ?? ''}
onChange={(e) => updateParam('fileName', e.target.value)}
placeholder="file.pdf"
/>
</div>
)}
{nodeType === 'sharepoint.copyFile' && (
<>
<div>
<label>Source folder</label>
<input
value={(params.sourceFolder as string) ?? ''}
onChange={(e) => updateParam('sourceFolder', e.target.value)}
placeholder="Source folder path"
/>
</div>
<div>
<label>Source file</label>
<input
value={(params.sourceFile as string) ?? ''}
onChange={(e) => updateParam('sourceFile', e.target.value)}
placeholder="Source file name"
/>
</div>
<div>
<label>Dest folder</label>
<input
value={(params.destFolder as string) ?? ''}
onChange={(e) => updateParam('destFolder', e.target.value)}
placeholder="Destination folder path"
/>
</div>
<div>
<label>Dest file</label>
<input
value={(params.destFile as string) ?? ''}
onChange={(e) => updateParam('destFile', e.target.value)}
placeholder="Destination file name"
/>
</div>
</>
)}
{connectionId && needsPath && (
<div className="browse-section" style={{ marginTop: 12 }}>
<label>Browse: {currentPath}</label>
<ul style={{ maxHeight: 150, overflow: 'auto', listStyle: 'none', paddingLeft: 0 }}>
{currentPath !== '/' && (
<li>
<button type="button" onClick={() => navigateTo(currentPath.replace(/\/[^/]+$/, '') || '/')}>
.. (parent)
</button>
</li>
)}
{browseItems.map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.isFolder ? navigateTo(e.path) : selectPath(e.path))}
title={e.path}
>
{e.isFolder ? '📁' : '📄'} {e.name}
</button>
</li>
))}
</ul>
</div>
)}
</>
);
};

View file

@ -1,6 +1,5 @@
/** /**
* Node config renderers - one per input node type. * Node config renderers - one per node type (input, ai, email, sharepoint).
* Add new node types here.
*/ */
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
@ -12,6 +11,9 @@ import { CommentNodeConfig } from './CommentNodeConfig';
import { ReviewNodeConfig } from './ReviewNodeConfig'; import { ReviewNodeConfig } from './ReviewNodeConfig';
import { SelectionNodeConfig } from './SelectionNodeConfig'; import { SelectionNodeConfig } from './SelectionNodeConfig';
import { ConfirmationNodeConfig } from './ConfirmationNodeConfig'; import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
import { AiNodeConfig } from './AiNodeConfig';
import { EmailNodeConfig } from './EmailNodeConfig';
import { SharePointNodeConfig } from './SharePointNodeConfig';
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>; export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
@ -23,4 +25,20 @@ export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
'input.review': ReviewNodeConfig, 'input.review': ReviewNodeConfig,
'input.selection': SelectionNodeConfig, 'input.selection': SelectionNodeConfig,
'input.confirmation': ConfirmationNodeConfig, 'input.confirmation': ConfirmationNodeConfig,
'ai.prompt': AiNodeConfig,
'ai.webResearch': AiNodeConfig,
'ai.summarizeDocument': AiNodeConfig,
'ai.translateDocument': AiNodeConfig,
'ai.convertDocument': AiNodeConfig,
'ai.generateDocument': AiNodeConfig,
'ai.generateCode': AiNodeConfig,
'email.checkEmail': EmailNodeConfig,
'email.searchEmail': EmailNodeConfig,
'email.draftEmail': EmailNodeConfig,
'sharepoint.findFile': SharePointNodeConfig,
'sharepoint.readFile': SharePointNodeConfig,
'sharepoint.uploadFile': SharePointNodeConfig,
'sharepoint.listFiles': SharePointNodeConfig,
'sharepoint.downloadFile': SharePointNodeConfig,
'sharepoint.copyFile': SharePointNodeConfig,
}; };

View file

@ -2,9 +2,15 @@
* Shared types for node config renderers * Shared types for node config renderers
*/ */
import type { ApiRequestFunction } from '../../../api/automation2Api';
export type FormField = { name?: string; type?: string; label?: string; required?: boolean }; export type FormField = { name?: string; type?: string; label?: string; required?: boolean };
export interface NodeConfigRendererProps { export interface NodeConfigRendererProps {
params: Record<string, unknown>; params: Record<string, unknown>;
updateParam: (key: string, value: unknown) => void; updateParam: (key: string, value: unknown) => void;
/** For Email/SharePoint: fetch connections and browse */
instanceId?: string;
request?: ApiRequestFunction;
nodeType?: string;
} }

View file

@ -1,28 +1,15 @@
/** /**
* Automation2 Flow Editor - Constants * Automation2 Flow Editor - Constants
* I/O method configuration, category ordering. * Category ordering for node sidebar.
*/ */
/** I/O nodes: order for sub-groups (KI, Kontext, Outlook, etc.) */
export const IO_METHOD_ORDER = [
'ai',
'context',
'outlook',
'sharepoint',
'jira',
'trustee',
'chatbot',
] as const;
export const IO_METHOD_LABELS: Record<string, Record<string, string>> = {
ai: { de: 'KI', en: 'AI', fr: 'IA' },
context: { de: 'Kontext', en: 'Context', fr: 'Contexte' },
outlook: { de: 'Outlook', en: 'Outlook', fr: 'Outlook' },
sharepoint: { de: 'SharePoint', en: 'SharePoint', fr: 'SharePoint' },
jira: { de: 'Jira', en: 'Jira', fr: 'Jira' },
trustee: { de: 'Trustee', en: 'Trustee', fr: 'Trustee' },
chatbot: { de: 'Chatbot', en: 'Chatbot', fr: 'Chatbot' },
};
/** Default category display order */ /** Default category display order */
export const CATEGORY_ORDER = ['trigger', 'input', 'flow', 'data', 'io'] as const; export const CATEGORY_ORDER = [
'trigger',
'input',
'flow',
'data',
'ai',
'email',
'sharepoint',
] as const;

View file

@ -4,7 +4,6 @@
import type React from 'react'; import type React from 'react';
import { CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from './categoryIcons'; import { CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from './categoryIcons';
import { IO_METHOD_LABELS } from './constants';
/** Resolve localized label from string or { de, en, fr } object */ /** Resolve localized label from string or { de, en, fr } object */
export function getLabel( export function getLabel(
@ -22,10 +21,5 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
return CATEGORY_ICONS[categoryId] ?? DEFAULT_CATEGORY_ICON; return CATEGORY_ICONS[categoryId] ?? DEFAULT_CATEGORY_ICON;
} }
/** Get label for I/O method (ai, context, outlook, ...) */
export function getIoMethodLabel(method: string, lang: string): string {
return IO_METHOD_LABELS[method]?.[lang] ?? IO_METHOD_LABELS[method]?.en ?? method;
}
/** Function type for resolving localized labels */ /** Function type for resolving localized labels */
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string; export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;