finished email nodes
This commit is contained in:
parent
835428ac5f
commit
af58d5a868
12 changed files with 631 additions and 115 deletions
|
|
@ -289,3 +289,68 @@ export async function completeTask(
|
|||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,9 +50,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
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 [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||
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(
|
||||
(nodeTypeId: string, x: number, y: number) => {
|
||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||
|
|
@ -280,9 +270,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
onFilterChange={setFilter}
|
||||
language={language}
|
||||
expandedCategories={expandedCategories}
|
||||
expandedIoMethods={expandedIoMethods}
|
||||
onToggleCategory={toggleCategory}
|
||||
onToggleIoMethod={toggleIoMethod}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -318,12 +306,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
onSelectionChange={setSelectedNode}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode?.type?.startsWith('input.') && (
|
||||
{selectedNode &&
|
||||
['input.', 'ai.', 'email.', 'sharepoint.'].some((p) =>
|
||||
selectedNode.type.startsWith(p)
|
||||
) && (
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
||||
language={language}
|
||||
onParametersChange={handleNodeParametersChange}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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/.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { CanvasNode } from './FlowCanvas';
|
||||
import type { NodeType } from '../../api/automation2Api';
|
||||
import type { ApiRequestFunction } from '../../api/automation2Api';
|
||||
import { getLabel } from './utils';
|
||||
import { NODE_CONFIG_REGISTRY } from './configs';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
|
@ -15,13 +16,19 @@ interface NodeConfigPanelProps {
|
|||
nodeType: NodeType | undefined;
|
||||
language: string;
|
||||
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> = ({
|
||||
node,
|
||||
nodeType,
|
||||
language,
|
||||
onParametersChange,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
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 || !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];
|
||||
if (!ConfigRenderer) {
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||
<p>Keine Konfiguration für {node.type}</p>
|
||||
<p>No configuration for {node.type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,7 +58,13 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
|||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* 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 { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||
import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
|
||||
import { IO_METHOD_ORDER, CATEGORY_ORDER } from './constants';
|
||||
import { getLabel, getIoMethodLabel } from './utils';
|
||||
import { CATEGORY_ORDER } from './constants';
|
||||
import { getLabel } from './utils';
|
||||
import { NodeListItem } from './NodeListItem';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
|
|
@ -18,9 +18,7 @@ interface NodeSidebarProps {
|
|||
onFilterChange: (value: string) => void;
|
||||
language: string;
|
||||
expandedCategories: Set<string>;
|
||||
expandedIoMethods: Set<string>;
|
||||
onToggleCategory: (id: string) => void;
|
||||
onToggleIoMethod: (method: string) => void;
|
||||
}
|
||||
|
||||
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||
|
|
@ -30,9 +28,7 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
|||
onFilterChange,
|
||||
language,
|
||||
expandedCategories,
|
||||
expandedIoMethods,
|
||||
onToggleCategory,
|
||||
onToggleIoMethod,
|
||||
}) => {
|
||||
const filteredNodeTypes = useMemo(() => {
|
||||
if (!filter.trim()) return nodeTypes;
|
||||
|
|
@ -55,25 +51,6 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
|||
return map;
|
||||
}, [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 seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
|
@ -109,44 +86,6 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
|||
const isExpanded = expandedCategories.has(catId);
|
||||
const catLabel = categories.find((c) => c.id === 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] || [];
|
||||
return (
|
||||
<div key={catId} className={styles.categoryGroup}>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@
|
|||
*/
|
||||
|
||||
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> = {
|
||||
trigger: <FaPlay />,
|
||||
input: <FaUser />,
|
||||
flow: <FaCodeBranch />,
|
||||
data: <FaDatabase />,
|
||||
io: <FaPlug />,
|
||||
ai: <FaRobot />,
|
||||
email: <FaEnvelope />,
|
||||
sharepoint: <FaCloud />,
|
||||
human: <FaUser />,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
240
src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
Normal file
240
src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
/**
|
||||
* Node config renderers - one per input node type.
|
||||
* Add new node types here.
|
||||
* Node config renderers - one per node type (input, ai, email, sharepoint).
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
|
|
@ -12,6 +11,9 @@ import { CommentNodeConfig } from './CommentNodeConfig';
|
|||
import { ReviewNodeConfig } from './ReviewNodeConfig';
|
||||
import { SelectionNodeConfig } from './SelectionNodeConfig';
|
||||
import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
|
||||
import { AiNodeConfig } from './AiNodeConfig';
|
||||
import { EmailNodeConfig } from './EmailNodeConfig';
|
||||
import { SharePointNodeConfig } from './SharePointNodeConfig';
|
||||
|
||||
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
|
||||
|
||||
|
|
@ -23,4 +25,20 @@ export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
|||
'input.review': ReviewNodeConfig,
|
||||
'input.selection': SelectionNodeConfig,
|
||||
'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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
* Shared types for node config renderers
|
||||
*/
|
||||
|
||||
import type { ApiRequestFunction } from '../../../api/automation2Api';
|
||||
|
||||
export type FormField = { name?: string; type?: string; label?: string; required?: boolean };
|
||||
|
||||
export interface NodeConfigRendererProps {
|
||||
params: Record<string, unknown>;
|
||||
updateParam: (key: string, value: unknown) => void;
|
||||
/** For Email/SharePoint: fetch connections and browse */
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,15 @@
|
|||
/**
|
||||
* 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 */
|
||||
export const CATEGORY_ORDER = ['trigger', 'input', 'flow', 'data', 'io'] as const;
|
||||
export const CATEGORY_ORDER = [
|
||||
'trigger',
|
||||
'input',
|
||||
'flow',
|
||||
'data',
|
||||
'ai',
|
||||
'email',
|
||||
'sharepoint',
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import type React from 'react';
|
||||
import { CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from './categoryIcons';
|
||||
import { IO_METHOD_LABELS } from './constants';
|
||||
|
||||
/** Resolve localized label from string or { de, en, fr } object */
|
||||
export function getLabel(
|
||||
|
|
@ -22,10 +21,5 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
|
|||
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 */
|
||||
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue