1072 lines
51 KiB
TypeScript
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;
|