/** * AutomationEditor * * Full-screen editor for AutomationDefinitions and AutomationTemplates. * Features: * - Mode toggle: 'definition' vs 'template' * - Custom form fields for definition/template properties * - JSON template editor with syntax highlighting * - Integrated ActionsPanel for action discovery and copy/paste * - Placeholder extraction and editing */ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa'; import { Popup } from '../UiComponents/Popup'; import { ActionsPanel } from '../ActionsPanel'; import { ProviderMultiSelect, _defaultProviderSelection, _migrateFromLegacy, _toBackendProviders } from '../ProviderSelector'; import type { ProviderSelection } from '../ProviderSelector'; import { useBilling } from '../../hooks/useBilling'; import { useToast } from '../../contexts/ToastContext'; import { useLanguage } from '../../providers/language/LanguageContext'; import { useWorkflowActions } from '../../hooks/useAutomations'; import { useApiRequest } from '../../hooks/useApi'; import api from '../../api'; import type { Automation, AutomationTemplate } from '../../hooks/useAutomations'; import styles from './AutomationEditor.module.css'; // SharePoint folder picker types interface SiteOption { value: string; label: string; siteId: string; siteName: string; webUrl: string; path: string; } interface FolderOption { value: string; label: string; siteId: string; folderName: string; path: string; } // SharePoint Folder Picker Component interface SharepointFolderPickerProps { connectionReference: string; value: string; onChange: (path: string) => void; } const SharepointFolderPicker: React.FC = ({ connectionReference, value, onChange }) => { const [siteOptions, setSiteOptions] = useState([]); const [folderOptions, setFolderOptions] = useState([]); const [selectedSite, setSelectedSite] = useState(null); const [currentPath, setCurrentPath] = useState(''); const [isLoadingSites, setIsLoadingSites] = useState(false); const [isLoadingFolders, setIsLoadingFolders] = useState(false); const [error, setError] = useState(null); const [isExpanded, setIsExpanded] = useState(false); // Load sites when connection reference changes useEffect(() => { if (!connectionReference || !isExpanded) return; const loadSites = async () => { setIsLoadingSites(true); setError(null); try { const params = new URLSearchParams({ connectionReference }); const response = await api.get(`/api/sharepoint/folder-options?${params}`); setSiteOptions(response.data || []); } catch (err: any) { console.error('Failed to load sites:', err); setError(err.response?.data?.detail || 'Fehler beim Laden der SharePoint-Sites'); setSiteOptions([]); } finally { setIsLoadingSites(false); } }; loadSites(); }, [connectionReference, isExpanded]); // Load folders when site or path changes useEffect(() => { if (!selectedSite || !connectionReference) return; const loadFolders = async () => { setIsLoadingFolders(true); setError(null); try { const params = new URLSearchParams({ connectionReference, siteId: selectedSite.siteId }); if (currentPath) params.append('path', currentPath); const response = await api.get(`/api/sharepoint/folder-options?${params}`); setFolderOptions(response.data || []); } catch (err: any) { console.error('Failed to load folders:', err); setError(err.response?.data?.detail || 'Fehler beim Laden der Ordner'); setFolderOptions([]); } finally { setIsLoadingFolders(false); } }; loadFolders(); }, [selectedSite, currentPath, connectionReference]); const handleSiteSelect = (siteId: string) => { const site = siteOptions.find(s => s.siteId === siteId); setSelectedSite(site || null); setCurrentPath(''); setFolderOptions([]); }; const handleFolderNavigate = (folder: FolderOption) => { setCurrentPath(folder.path); }; const handleFolderSelect = (folder: FolderOption) => { const fullPath = `${selectedSite?.path || ''}/${folder.path}`; onChange(fullPath); setIsExpanded(false); }; const handleGoUp = () => { if (!currentPath) return; const parts = currentPath.split('/'); parts.pop(); setCurrentPath(parts.join('/')); }; const handleSelectCurrentFolder = () => { if (!selectedSite) return; const fullPath = currentPath ? `${selectedSite.path}/${currentPath}` : selectedSite.path; onChange(fullPath); setIsExpanded(false); }; if (!connectionReference) { return (
onChange(e.target.value)} placeholder="/sites/SiteName/Ordner/Pfad" /> Keine Verbindung ausgewählt - manueller Pfad
); } return (
onChange(e.target.value)} placeholder="/sites/SiteName/Ordner/Pfad" />
setIsExpanded(false)} size="large" closable={true} > {error && (
{error}
)} {/* Site Selection */}
{isLoadingSites ? (
Lade Sites...
) : ( )}
{/* Folder Browser */} {selectedSite && (
{selectedSite.siteName} {currentPath && / {currentPath}}
{currentPath && (
..
)} {isLoadingFolders ? (
Lade Ordner...
) : folderOptions.length === 0 ? (
Keine Unterordner
) : ( folderOptions.map(folder => (
handleFolderNavigate(folder)} > {folder.folderName}
)) )}
)}
); }; // Local TextMultilingual type that supports both 'ge' and 'de' for German interface LocalTextMultilingual { en: string; ge?: string; de?: string; fr?: string; it?: string; } // Placeholder type info extracted from actions interface PlaceholderTypeInfo { name: string; frontendType: string; frontendOptions?: string | string[]; description?: string; required?: boolean; connectionPlaceholder?: string; // For sharepointFolder: which placeholder holds the connection reference } // Editor mode type export type EditorMode = 'definition' | 'template'; // Props interface export interface AutomationEditorProps { /** Editor mode: 'definition' for AutomationDefinition, 'template' for AutomationTemplate */ mode: EditorMode; /** Initial data for editing (null for create) */ initialData?: Automation | AutomationTemplate | null; /** Callback when saving */ onSave: (data: any) => Promise; /** Callback when canceling */ onCancel: () => void; /** Whether the editor is in a saving state */ saving?: boolean; /** Custom title override */ title?: string; } // Available languages for multilingual fields const LANGUAGES = [ { code: 'en', label: 'English' }, { code: 'de', label: 'Deutsch' }, { code: 'fr', label: 'Français' }, ]; /** * Extract {{KEY:name}} placeholders from JSON string */ function _extractPlaceholdersFromJson(jsonString: string): string[] { const regex = /\{\{KEY:(\w+)\}\}/g; const keys: string[] = []; let match; while ((match = regex.exec(jsonString)) !== null) { if (!keys.includes(match[1])) { keys.push(match[1]); } } return keys; } /** * Validate JSON string */ function _validateJson(jsonString: string): { valid: boolean; error?: string } { if (!jsonString || jsonString.trim() === '') { return { valid: false, error: 'Template JSON darf nicht leer sein' }; } try { JSON.parse(jsonString); return { valid: true }; } catch (e: any) { return { valid: false, error: `Ungültiges JSON: ${e.message}` }; } } export const AutomationEditor: React.FC = ({ mode, initialData, onSave, onCancel, saving = false, title }) => { const { showError, showSuccess } = useToast(); const { currentLanguage } = useLanguage(); const jsonTextareaRef = useRef(null); // State const [showActionsPanel, setShowActionsPanel] = useState(true); const [activeLanguageTab, setActiveLanguageTab] = useState<'en' | 'de' | 'fr'>((currentLanguage as 'en' | 'de' | 'fr') || 'de'); // Definition fields const [label, setLabel] = useState(''); const [schedule, setSchedule] = useState('0 22 * * *'); const [active, setActive] = useState(false); const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); const { allowedProviders: billingProviders } = useBilling(); // Template multilingual fields const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); const [overviewMulti, setOverviewMulti] = useState({ en: '', de: '' }); // Common fields const [templateJson, setTemplateJson] = useState(''); const [jsonError, setJsonError] = useState(null); const [placeholders, setPlaceholders] = useState>({}); const [isSaving, setIsSaving] = useState(false); // Actions and placeholder type info const { actions, fetchActions } = useWorkflowActions(); const { request } = useApiRequest(); const [connectionOptions, setConnectionOptions] = useState>([]); const [loadingConnections, setLoadingConnections] = useState(false); // Load actions on mount useEffect(() => { fetchActions(); }, [fetchActions]); // Load user connections for userConnection type placeholders useEffect(() => { const loadConnections = async () => { setLoadingConnections(true); try { const response = await request({ url: '/api/connections/', method: 'get' }); // Response is PaginatedResponse with items array // Backend provides connectionReference and displayLabel computed fields const connections = response?.items || response || []; if (Array.isArray(connections)) { const options = connections .filter((conn: any) => conn.connectionReference && conn.displayLabel) .map((conn: any) => ({ value: conn.connectionReference, label: conn.displayLabel })); setConnectionOptions(options); } } catch (err) { console.error('Error loading connections:', err); } finally { setLoadingConnections(false); } }; loadConnections(); }, [request]); // Helper function to extract placeholder names from a value (string or array) // Returns array of { name, isArrayElement } objects const extractPlaceholders = (value: any, isArrayElement = false): { name: string; isArrayElement: boolean }[] => { const placeholders: { name: string; isArrayElement: boolean }[] = []; const regex = /\{\{KEY:(\w+)\}\}/g; if (typeof value === 'string') { let match; while ((match = regex.exec(value)) !== null) { placeholders.push({ name: match[1], isArrayElement }); } } else if (Array.isArray(value)) { for (const item of value) { // Mark items inside arrays as array elements placeholders.push(...extractPlaceholders(item, true)); } } return placeholders; }; // Build a map of placeholder names to their type info from actions const placeholderTypeMap = useMemo(() => { const typeMap: Record = {}; // Parse the template JSON to find which actions are used try { const parsed = JSON.parse(templateJson); const tasks = parsed?.tasks || []; for (const task of tasks) { // Support both 'actions' and 'actionList' field names const taskActions = task?.actionList || task?.actions || []; for (const action of taskActions) { const execMethod = action?.execMethod; const execAction = action?.execAction; const execParams = action?.execParameters || {}; // Find matching action definition const actionDef = actions.find( a => a.method === execMethod && a.action === execAction ); if (actionDef) { // First pass: Find connectionReference placeholder for this action let connectionPlaceholder: string | undefined; const connectionRefValue = execParams['connectionReference']; const connPlaceholders = extractPlaceholders(connectionRefValue); if (connPlaceholders.length > 0) { connectionPlaceholder = connPlaceholders[0].name; } // Second pass: Check each parameter for {{KEY:xxx}} placeholders for (const [paramName, paramValue] of Object.entries(execParams)) { const foundPlaceholders = extractPlaceholders(paramValue); for (const placeholder of foundPlaceholders) { const placeholderName = placeholder.name; // Find parameter definition const paramDef = actionDef.parameters.find(p => p.name === paramName); if (paramDef && !typeMap[placeholderName]) { // If placeholder is inside an array, always use text field const effectiveFrontendType = placeholder.isArrayElement ? 'text' : (paramDef.frontendType || 'text'); typeMap[placeholderName] = { name: placeholderName, frontendType: effectiveFrontendType, frontendOptions: paramDef.frontendOptions, description: paramDef.description, required: paramDef.required, // For sharepointFolder types, store the associated connection placeholder connectionPlaceholder: effectiveFrontendType === 'sharepointFolder' ? connectionPlaceholder : undefined }; } } } } } } } catch { // Ignore parse errors } return typeMap; }, [templateJson, actions]); // Initialize data from initialData useEffect(() => { if (initialData) { if (mode === 'template') { const tmpl = initialData as AutomationTemplate; // Convert TextMultilingual (with 'ge') to LocalTextMultilingual (with 'de') const tmplLabel = tmpl.label || { en: '' }; const tmplOverview = tmpl.overview || { en: '' }; setLabelMulti({ en: tmplLabel.en || '', de: (tmplLabel as any).de || (tmplLabel as any).ge || '', fr: tmplLabel.fr || '' }); setOverviewMulti({ en: tmplOverview.en || '', de: (tmplOverview as any).de || (tmplOverview as any).ge || '', fr: tmplOverview.fr || '' }); } else { const def = initialData as Automation; setLabel(def.label || ''); setSchedule(def.schedule || '0 22 * * *'); setActive(def.active ?? false); setProviderSelection(_migrateFromLegacy(def.allowedProviders || [])); } // Extract template JSON const template = initialData.template; if (template) { const jsonStr = typeof template === 'string' ? template : JSON.stringify(template, null, 2); setTemplateJson(jsonStr); // Extract placeholders const keys = _extractPlaceholdersFromJson(jsonStr); const existingPlaceholders = (initialData as Automation).placeholders || {}; const newPlaceholders: Record = {}; keys.forEach(key => { newPlaceholders[key] = existingPlaceholders[key] || ''; }); setPlaceholders(newPlaceholders); } } else { // New item - set defaults if (mode === 'template') { setLabelMulti({ en: '', de: '' }); setOverviewMulti({ en: '', de: '' }); } else { setLabel(''); setSchedule('0 22 * * *'); setActive(false); } setTemplateJson('{\n "overview": "Beschreibung des Workflows",\n "tasks": [\n {\n "objective": "Aufgabe beschreiben",\n "actions": []\n }\n ]\n}'); setPlaceholders({}); } }, [initialData, mode]); // Update placeholders when JSON changes const handleJsonChange = useCallback((newJson: string) => { setTemplateJson(newJson); // Validate JSON const validation = _validateJson(newJson); setJsonError(validation.error || null); // Extract and update placeholders if (validation.valid) { const keys = _extractPlaceholdersFromJson(newJson); setPlaceholders(prev => { const newPlaceholders: Record = {}; keys.forEach(key => { newPlaceholders[key] = prev[key] || ''; }); return newPlaceholders; }); } }, []); // Format JSON (prettify) const handleFormatJson = useCallback(() => { try { const parsed = JSON.parse(templateJson); const formatted = JSON.stringify(parsed, null, 2); setTemplateJson(formatted); setJsonError(null); showSuccess('JSON formatiert'); } catch (e: any) { showError('JSON kann nicht formatiert werden: ' + e.message); } }, [templateJson, showSuccess, showError]); // Handle placeholder value change const handlePlaceholderChange = useCallback((key: string, value: string) => { setPlaceholders(prev => ({ ...prev, [key]: value })); }, []); // Handle multilingual field change const handleMultilingualChange = useCallback(( setter: React.Dispatch>, lang: string, value: string ) => { setter(prev => ({ ...prev, [lang]: value })); }, []); // Insert action JSON from ActionsPanel const handleInsertAction = useCallback((actionJson: string) => { if (jsonTextareaRef.current) { const textarea = jsonTextareaRef.current; const start = textarea.selectionStart; const end = textarea.selectionEnd; const currentValue = textarea.value; // Insert at cursor position const newValue = currentValue.substring(0, start) + actionJson + currentValue.substring(end); setTemplateJson(newValue); // Validate const validation = _validateJson(newValue); setJsonError(validation.error || null); // Focus and set cursor after inserted text setTimeout(() => { textarea.focus(); textarea.selectionStart = start + actionJson.length; textarea.selectionEnd = start + actionJson.length; }, 0); showSuccess('Action eingefügt'); } }, [showSuccess]); // Handle save const handleSave = useCallback(async () => { // Validate JSON const validation = _validateJson(templateJson); if (!validation.valid) { showError(validation.error || 'Ungültiges JSON'); return; } // Validate required fields if (mode === 'template') { if (!labelMulti?.en && !labelMulti?.de) { showError('Label (mindestens eine Sprache) ist erforderlich'); return; } } else { if (!label) { showError('Label ist erforderlich'); return; } } setIsSaving(true); try { // Build save data based on mode let saveData: Record; if (mode === 'template') { saveData = { label: labelMulti, overview: overviewMulti, template: templateJson }; } else { saveData = { label, schedule, active, template: templateJson, placeholders, allowedProviders: _toBackendProviders(providerSelection, billingProviders), }; } // Preserve existing fields from initialData if (initialData) { if ('mandateId' in initialData) saveData.mandateId = initialData.mandateId; if ('featureInstanceId' in initialData) saveData.featureInstanceId = (initialData as Automation).featureInstanceId; } await onSave(saveData); } catch (err: any) { showError(`Fehler beim Speichern: ${err.message}`); } finally { setIsSaving(false); } }, [label, schedule, active, providerSelection, billingProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); // Computed values const editorTitle = title || (mode === 'template' ? (initialData ? 'Vorlage bearbeiten' : 'Neue Vorlage') : (initialData ? 'Automatisierung bearbeiten' : 'Neue Automatisierung')); const placeholderKeys = Object.keys(placeholders); const isFormSaving = saving || isSaving; return (
{jsonError && ( JSON enthält Fehler )}
} >
{/* Content */}
{/* Form Panel (Left) */}

Einstellungen

{/* Form Fields based on mode */} {mode === 'template' ? ( /* Template Mode: Multilingual fields */
{/* Language Tabs */}
{LANGUAGES.map(lang => ( ))}
{/* Label Field */}
handleMultilingualChange(setLabelMulti, activeLanguageTab, e.target.value)} placeholder={`Label (${activeLanguageTab.toUpperCase()})`} />

Name der Vorlage

{/* Overview Field */}