ui-nyla/src/components/AutomationEditor/AutomationEditor.tsx
2026-03-30 23:03:33 +02:00

1072 lines
51 KiB
TypeScript

/**
* 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<SharepointFolderPickerProps> = ({ connectionReference, value, onChange }) => {
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [folderOptions, setFolderOptions] = useState<FolderOption[]>([]);
const [selectedSite, setSelectedSite] = useState<SiteOption | null>(null);
const [currentPath, setCurrentPath] = useState<string>('');
const [isLoadingSites, setIsLoadingSites] = useState(false);
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className={styles.sharepointFolderInput}>
<input
type="text"
className={styles.placeholderInput}
value={value}
onChange={e => onChange(e.target.value)}
placeholder="/sites/SiteName/Ordner/Pfad"
/>
<span className={styles.sharepointFolderHint}>
Keine Verbindung ausgewählt - manueller Pfad
</span>
</div>
);
}
return (
<div className={styles.sharepointFolderPicker}>
<div className={styles.sharepointFolderHeader}>
<input
type="text"
className={styles.placeholderInput}
value={value}
onChange={e => onChange(e.target.value)}
placeholder="/sites/SiteName/Ordner/Pfad"
/>
<button
type="button"
className={styles.sharepointBrowseButton}
onClick={() => setIsExpanded(!isExpanded)}
>
<FaFolderOpen /> Durchsuchen
</button>
</div>
<Popup
isOpen={isExpanded}
title="SharePoint Ordner auswählen"
onClose={() => setIsExpanded(false)}
size="large"
closable={true}
>
{error && (
<div className={styles.sharepointError}>
<FaExclamationTriangle /> {error}
</div>
)}
{/* Site Selection */}
<div className={styles.sharepointSection}>
<label>SharePoint Site:</label>
{isLoadingSites ? (
<div className={styles.sharepointLoading}>
<FaSpinner className={styles.spinner} /> Lade Sites...
</div>
) : (
<select
className={styles.sharepointSelect}
value={selectedSite?.siteId || ''}
onChange={e => handleSiteSelect(e.target.value)}
>
<option value="">-- Site auswählen --</option>
{siteOptions.map(site => (
<option key={site.siteId} value={site.siteId}>
{site.siteName}
</option>
))}
</select>
)}
</div>
{/* Folder Browser */}
{selectedSite && (
<div className={styles.sharepointSection}>
<div className={styles.sharepointBreadcrumb}>
<span>{selectedSite.siteName}</span>
{currentPath && <span> / {currentPath}</span>}
</div>
<div className={styles.sharepointFolderList}>
{currentPath && (
<div
className={styles.sharepointFolderItem}
onClick={handleGoUp}
>
<FaArrowUp /> ..
</div>
)}
{isLoadingFolders ? (
<div className={styles.sharepointLoading}>
<FaSpinner className={styles.spinner} /> Lade Ordner...
</div>
) : folderOptions.length === 0 ? (
<div className={styles.sharepointEmpty}>
Keine Unterordner
</div>
) : (
folderOptions.map(folder => (
<div
key={folder.path}
className={styles.sharepointFolderItem}
>
<FaFolder />
<span
className={styles.folderName}
onClick={() => handleFolderNavigate(folder)}
>
{folder.folderName}
</span>
<button
type="button"
className={styles.selectFolderButton}
onClick={() => handleFolderSelect(folder)}
>
Auswählen
</button>
</div>
))
)}
</div>
<button
type="button"
className={styles.selectCurrentFolderButton}
onClick={handleSelectCurrentFolder}
>
Aktuellen Ordner verwenden: {currentPath || '(Root)'}
</button>
</div>
)}
</Popup>
</div>
);
};
// 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<void>;
/** 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<AutomationEditorProps> = ({
mode,
initialData,
onSave,
onCancel,
saving = false,
title
}) => {
const { showError, showSuccess } = useToast();
const { currentLanguage } = useLanguage();
const jsonTextareaRef = useRef<HTMLTextAreaElement>(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<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders: billingProviders } = useBilling();
// Template multilingual fields
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
const [overviewMulti, setOverviewMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
// Common fields
const [templateJson, setTemplateJson] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const [placeholders, setPlaceholders] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
// Actions and placeholder type info
const { actions, fetchActions } = useWorkflowActions();
const { request } = useApiRequest();
const [connectionOptions, setConnectionOptions] = useState<Array<{ value: string; label: string }>>([]);
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<UserConnection> 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<string, PlaceholderTypeInfo> = {};
// 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<string, string> = {};
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<string, string> = {};
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<React.SetStateAction<LocalTextMultilingual>>,
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<string, any>;
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 (
<Popup
isOpen={true}
title={`${editorTitle} · ${mode === 'template' ? 'Template' : 'Definition'}`}
onClose={onCancel}
size="fullscreen"
closable={true}
className={styles.editorPopup}
footerContent={
<div className={styles.editorFooter}>
<div className={styles.footerLeft}>
{jsonError && (
<span style={{ color: 'var(--danger-color)', fontSize: '0.875rem' }}>
<FaExclamationTriangle style={{ marginRight: '0.375rem' }} />
JSON enthält Fehler
</span>
)}
</div>
<div className={styles.footerRight}>
<button
className={styles.secondaryButton}
onClick={onCancel}
disabled={isFormSaving}
>
Abbrechen
</button>
<button
className={styles.primaryButton}
onClick={handleSave}
disabled={isFormSaving || !!jsonError}
>
{isFormSaving ? (
<>Speichern...</>
) : (
<><FaSave style={{ marginRight: '0.375rem' }} /> Speichern</>
)}
</button>
</div>
</div>
}
>
<div className={styles.editorContainer}>
{/* Content */}
<div className={styles.editorContent}>
{/* Form Panel (Left) */}
<div className={styles.formPanel}>
<div className={styles.formPanelHeader}>
<h3 className={styles.formPanelTitle}>Einstellungen</h3>
</div>
<div className={styles.formPanelContent}>
{/* Form Fields based on mode */}
{mode === 'template' ? (
/* Template Mode: Multilingual fields */
<div className={styles.formFields}>
{/* Language Tabs */}
<div className={styles.languageTabs}>
{LANGUAGES.map(lang => (
<button
key={lang.code}
className={`${styles.languageTab} ${activeLanguageTab === lang.code ? styles.active : ''}`}
onClick={() => setActiveLanguageTab(lang.code as 'en' | 'de' | 'fr')}
>
{lang.label}
</button>
))}
</div>
{/* Label Field */}
<div className={styles.formGroup}>
<label className={styles.formLabel}>
Label <span className={styles.required}>*</span>
</label>
<input
type="text"
className={styles.formInput}
value={(labelMulti as any)[activeLanguageTab] || ''}
onChange={e => handleMultilingualChange(setLabelMulti, activeLanguageTab, e.target.value)}
placeholder={`Label (${activeLanguageTab.toUpperCase()})`}
/>
<p className={styles.formHint}>Name der Vorlage</p>
</div>
{/* Overview Field */}
<div className={styles.formGroup}>
<label className={styles.formLabel}>Beschreibung</label>
<textarea
className={styles.formTextarea}
value={(overviewMulti as any)[activeLanguageTab] || ''}
onChange={e => handleMultilingualChange(setOverviewMulti, activeLanguageTab, e.target.value)}
placeholder={`Beschreibung (${activeLanguageTab.toUpperCase()})`}
rows={3}
/>
<p className={styles.formHint}>Kurzbeschreibung der Vorlage</p>
</div>
</div>
) : (
/* Definition Mode: Simple fields */
<div className={styles.formFields}>
{/* Label Field */}
<div className={styles.formGroup}>
<label className={styles.formLabel}>
Label <span className={styles.required}>*</span>
</label>
<input
type="text"
className={styles.formInput}
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Name der Automatisierung"
/>
</div>
{/* Schedule Field */}
<div className={styles.formGroup}>
<label className={styles.formLabel}>Schedule (Cron)</label>
<input
type="text"
className={styles.formInput}
value={schedule}
onChange={e => setSchedule(e.target.value)}
placeholder="0 22 * * *"
/>
<p className={styles.formHint}>
Cron-Ausdruck für die Ausführungszeit (z.B. 0 22 * * * = täglich um 22:00)
</p>
</div>
{/* Active Checkbox */}
<div className={styles.formGroup}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={active}
onChange={e => setActive(e.target.checked)}
/>
<span>Aktiv</span>
</label>
<p className={styles.formHint}>
Automatisierung ist aktiv und wird planmässig ausgeführt
</p>
</div>
{/* Allowed AI Providers */}
<div className={styles.formGroup}>
<ProviderMultiSelect
selection={providerSelection}
onChange={setProviderSelection}
label="Erlaubte AI-Provider"
/>
<p className={styles.formHint}>
Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.
</p>
</div>
</div>
)}
{/* JSON Template Editor */}
<div className={styles.jsonEditorSection}>
<div className={styles.jsonEditorHeader}>
<div className={styles.jsonEditorLabelRow}>
<label className={styles.jsonEditorLabel}>
<FaCode style={{ marginRight: '0.5rem' }} />
Template JSON
</label>
<button
type="button"
className={styles.formatButton}
onClick={handleFormatJson}
title="JSON formatieren"
>
<FaMagic /> Formatieren
</button>
</div>
<span className={styles.jsonEditorHint}>
Verwende {"{{KEY:name}}"} für Platzhalter
</span>
</div>
<textarea
ref={jsonTextareaRef}
className={`${styles.jsonTextarea} ${jsonError ? styles.error : ''}`}
value={templateJson}
onChange={e => handleJsonChange(e.target.value)}
placeholder="Template JSON eingeben..."
spellCheck={false}
/>
{jsonError && (
<div className={styles.jsonError}>
<FaExclamationTriangle style={{ marginRight: '0.5rem' }} />
{jsonError}
</div>
)}
</div>
{/* Placeholders (only for definitions) */}
{mode === 'definition' && (
<div className={styles.placeholdersSection}>
<div className={styles.placeholdersHeader}>
<h4 className={styles.placeholdersTitle}>Platzhalter-Werte</h4>
<span className={styles.placeholdersHint}>
{placeholderKeys.length} gefunden
</span>
</div>
{placeholderKeys.length > 0 ? (
<div className={styles.placeholdersList}>
{placeholderKeys.map(key => {
const typeInfo = placeholderTypeMap[key];
const frontendType = typeInfo?.frontendType;
return (
<div key={key} className={styles.placeholderItem}>
<div className={styles.placeholderKeyRow}>
<span className={styles.placeholderKey}>{key}</span>
{typeInfo?.description && (
<span className={styles.placeholderDescription}>
{typeInfo.description}
</span>
)}
{frontendType && (
<span className={styles.placeholderType}>
{frontendType}
</span>
)}
</div>
{/* Render input based on frontendType - no fallback */}
{!typeInfo ? (
<div className={styles.placeholderError}>
<FaExclamationTriangle />
<span>Kein Typ gefunden für "{key}" - Action-Definition fehlt oder Actions nicht geladen</span>
</div>
) : frontendType === 'userConnection' ? (
<select
className={styles.placeholderSelect}
value={placeholders[key] || ''}
onChange={e => handlePlaceholderChange(key, e.target.value)}
disabled={loadingConnections}
>
<option value="">
{loadingConnections ? 'Lade Verbindungen...' : 'Verbindung auswählen...'}
</option>
{connectionOptions.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : frontendType === 'select' && typeInfo?.frontendOptions ? (
<select
className={styles.placeholderSelect}
value={placeholders[key] || ''}
onChange={e => handlePlaceholderChange(key, e.target.value)}
>
<option value="">Option auswählen...</option>
{(Array.isArray(typeInfo.frontendOptions)
? typeInfo.frontendOptions
: []
).map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : frontendType === 'number' ? (
<input
type="number"
className={styles.placeholderInput}
value={placeholders[key] || ''}
onChange={e => handlePlaceholderChange(key, e.target.value)}
placeholder={`Wert für ${key}...`}
/>
) : frontendType === 'checkbox' ? (
<label className={styles.placeholderCheckbox}>
<input
type="checkbox"
checked={placeholders[key] === 'true'}
onChange={e => handlePlaceholderChange(key, e.target.checked ? 'true' : 'false')}
/>
<span>Aktiviert</span>
</label>
) : frontendType === 'textarea' ? (
<textarea
className={styles.placeholderTextarea}
value={placeholders[key] || ''}
onChange={e => handlePlaceholderChange(key, e.target.value)}
placeholder={`Wert für ${key}...`}
rows={3}
/>
) : frontendType === 'sharepointFolder' ? (
<SharepointFolderPicker
connectionReference={typeInfo.connectionPlaceholder ? (placeholders[typeInfo.connectionPlaceholder] || '') : ''}
value={placeholders[key] || ''}
onChange={(path) => handlePlaceholderChange(key, path)}
/>
) : frontendType === 'text' ? (
<input
type="text"
className={styles.placeholderInput}
value={placeholders[key] || ''}
onChange={e => handlePlaceholderChange(key, e.target.value)}
placeholder={`Wert für ${key}...`}
/>
) : (
<div className={styles.placeholderError}>
<FaExclamationTriangle />
<span>Unbekannter frontendType: "{frontendType}"</span>
</div>
)}
</div>
);
})}
</div>
) : (
<div className={styles.noPlaceholders}>
Keine Platzhalter im Template gefunden.
Verwende {"{{KEY:name}}"} im JSON.
</div>
)}
</div>
)}
</div>
</div>
{/* Actions Panel (Right) */}
<div className={`${styles.actionsPanel} ${!showActionsPanel ? styles.actionsPanelCollapsed : ''}`}>
<button
className={styles.actionsPanelToggle}
onClick={() => setShowActionsPanel(!showActionsPanel)}
>
{showActionsPanel ? (
<><FaChevronRight /> Actions ausblenden</>
) : (
<><FaChevronLeft /> Actions</>
)}
</button>
{showActionsPanel && (
<div className={styles.actionsPanelContainer}>
<ActionsPanel
onInsert={handleInsertAction}
onCopy={() => showSuccess('JSON kopiert')}
/>
</div>
)}
</div>
</div>
</div>
</Popup>
);
};
export default AutomationEditor;