From fe5f4bf188ac149fc1f23f6f757c9dcbe7151593 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 3 Feb 2026 23:42:19 +0100 Subject: [PATCH] automation template/definition editor --- .../AutomationEditor.module.css | 939 +++++++++++++++ .../AutomationEditor/AutomationEditor.tsx | 1060 +++++++++++++++++ src/components/AutomationEditor/index.ts | 2 + .../trustee/TrusteeExpenseImportView.tsx | 26 +- .../views/trustee/TrusteePositionsView.tsx | 21 +- .../workflows/AutomationTemplatesPage.tsx | 149 +-- src/pages/workflows/AutomationsPage.tsx | 283 ++--- 7 files changed, 2232 insertions(+), 248 deletions(-) create mode 100644 src/components/AutomationEditor/AutomationEditor.module.css create mode 100644 src/components/AutomationEditor/AutomationEditor.tsx create mode 100644 src/components/AutomationEditor/index.ts diff --git a/src/components/AutomationEditor/AutomationEditor.module.css b/src/components/AutomationEditor/AutomationEditor.module.css new file mode 100644 index 0000000..1edfbba --- /dev/null +++ b/src/components/AutomationEditor/AutomationEditor.module.css @@ -0,0 +1,939 @@ +/** + * AutomationEditor Styles + * + * Full-screen editor with form on left and actions panel on right + */ + +.editorOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.editorContainer { + background: var(--surface-color, #ffffff); + border-radius: 12px; + width: 100%; + max-width: 1400px; + height: 90vh; + max-height: 900px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +/* Header */ +.editorHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-primary, #ffffff); + flex-shrink: 0; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 1rem; +} + +.editorTitle { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #333); + margin: 0; +} + +.modeBadge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; +} + +.modeBadge.template { + background: var(--info-bg, #e3f2fd); + color: var(--info-color, #1976d2); +} + +.modeBadge.definition { + background: var(--success-bg, #e8f5e9); + color: var(--success-color, #388e3c); +} + +.headerActions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: transparent; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + cursor: pointer; + color: var(--text-secondary, #666); + font-size: 1.125rem; + transition: all 0.2s; +} + +.closeButton:hover { + background: var(--bg-secondary, #f5f5f5); + color: var(--text-primary, #333); +} + +/* Main Content Area */ +.editorContent { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* Form Panel (Left Side) */ +.formPanel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + border-right: 1px solid var(--border-color, #e0e0e0); +} + +.formPanelHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.5rem; + background: var(--bg-secondary, #f5f5f5); + border-bottom: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; +} + +.formPanelTitle { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #666); + margin: 0; +} + +.formPanelContent { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +/* Actions Panel (Right Side) */ +.actionsPanel { + width: 400px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-secondary, #f8f9fa); +} + +.actionsPanelCollapsed { + width: 48px; +} + +.actionsPanelToggle { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.75rem; + background: var(--bg-secondary, #f5f5f5); + border: none; + border-bottom: 1px solid var(--border-color, #e0e0e0); + cursor: pointer; + color: var(--text-secondary, #666); + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; + flex-shrink: 0; +} + +.actionsPanelToggle:hover { + background: var(--bg-hover, #e8e8e8); + color: var(--text-primary, #333); +} + +.actionsPanelToggle svg { + margin-right: 0.5rem; +} + +.actionsPanelCollapsed .actionsPanelToggle { + writing-mode: vertical-rl; + text-orientation: mixed; + padding: 1rem 0.75rem; + height: 100%; +} + +.actionsPanelCollapsed .actionsPanelToggle svg { + margin-right: 0; + margin-bottom: 0.5rem; + transform: rotate(90deg); +} + +.actionsPanelContainer { + flex: 1; + overflow: hidden; +} + +/* Footer */ +.editorFooter { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-primary, #ffffff); + flex-shrink: 0; +} + +.footerLeft { + display: flex; + align-items: center; + gap: 1rem; +} + +.footerRight { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Buttons */ +.primaryButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.primaryButton:hover:not(:disabled) { + background: var(--primary-dark, #d94d3a); +} + +.primaryButton:active:not(:disabled) { + transform: scale(0.98); +} + +.primaryButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.secondaryButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + background: var(--surface-color, #ffffff); + color: var(--text-primary, #333); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.secondaryButton:hover:not(:disabled) { + background: var(--bg-secondary, #f5f5f5); + border-color: var(--text-secondary, #666); +} + +.secondaryButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.dangerButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + background: var(--danger-color, #dc3545); + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.dangerButton:hover:not(:disabled) { + background: var(--danger-dark, #c82333); +} + +/* JSON Editor Section */ +.jsonEditorSection { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color, #e0e0e0); +} + +.jsonEditorHeader { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.jsonEditorLabelRow { + display: flex; + justify-content: space-between; + align-items: center; +} + +.jsonEditorLabel { + display: flex; + align-items: center; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.jsonEditorHint { + font-size: 0.75rem; + color: var(--text-tertiary, #999); +} + +.formatButton { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: var(--bg-secondary, #f5f5f5); + color: var(--text-secondary, #666); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.formatButton:hover { + background: var(--primary-color, #f25843); + color: white; + border-color: var(--primary-color, #f25843); +} + +.jsonTextarea { + width: 100%; + min-height: 300px; + padding: 1rem; + font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-size: 0.8125rem; + line-height: 1.5; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + background: var(--bg-code, #1e1e1e); + color: var(--text-code, #d4d4d4); + resize: vertical; + tab-size: 2; +} + +.jsonTextarea:focus { + outline: none; + border-color: var(--primary-color, #f25843); + box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1); +} + +.jsonTextarea.error { + border-color: var(--danger-color, #dc3545); +} + +.jsonError { + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--danger-bg, #fef2f2); + color: var(--danger-color, #dc3545); + border-radius: 4px; + font-size: 0.8125rem; +} + +/* Placeholders Section */ +.placeholdersSection { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color, #e0e0e0); +} + +.placeholdersHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.placeholdersTitle { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.placeholdersHint { + font-size: 0.75rem; + color: var(--text-tertiary, #999); +} + +.placeholdersList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.placeholderItem { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--bg-secondary, #f5f5f5); + border-radius: 6px; +} + +.placeholderKeyRow { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.placeholderKey { + padding: 0.375rem 0.625rem; + background: var(--bg-code, #e9ecef); + border-radius: 4px; + font-family: monospace; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.placeholderDescription { + font-size: 0.75rem; + color: var(--text-secondary, #666); + flex: 1; +} + +.placeholderType { + padding: 0.25rem 0.5rem; + background: var(--info-bg, #e3f2fd); + color: var(--info-color, #1976d2); + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; +} + +.placeholderError { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--error-bg, #ffebee); + color: var(--error-color, #c62828); + border: 1px solid var(--error-border, #ef9a9a); + border-radius: 6px; + font-size: 0.8125rem; +} + +.placeholderError svg { + flex-shrink: 0; +} + +.sharepointFolderInput { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sharepointFolderHint { + font-size: 0.75rem; + color: var(--text-secondary, #666); + font-style: italic; +} + +/* SharePoint Folder Picker */ +.sharepointFolderPicker { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sharepointFolderHeader { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.sharepointFolderHeader .placeholderInput { + flex: 1; +} + +.sharepointBrowseButton { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: var(--secondary-button-bg, #f0f0f0); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary, #333); + white-space: nowrap; + transition: background-color 0.15s; +} + +.sharepointBrowseButton:hover { + background: var(--secondary-button-hover-bg, #e0e0e0); +} + +.sharepointFolderBrowser { + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + padding: 1rem; + background: var(--bg-secondary, #fafafa); +} + +.sharepointError { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--danger-bg, #fff0f0); + color: var(--danger-color, #d32f2f); + border-radius: 4px; + margin-bottom: 0.75rem; + font-size: 0.875rem; +} + +.sharepointSection { + margin-bottom: 1rem; +} + +.sharepointSection:last-child { + margin-bottom: 0; +} + +.sharepointSection label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #666); + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.sharepointLoading { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + color: var(--text-secondary, #666); + font-size: 0.875rem; +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.sharepointSelect { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary, white); + cursor: pointer; +} + +.sharepointSelect:focus { + outline: none; + border-color: var(--primary-color, #1976d2); +} + +.sharepointBreadcrumb { + font-size: 0.75rem; + color: var(--text-secondary, #666); + margin-bottom: 0.5rem; + padding: 0.25rem 0; + border-bottom: 1px solid var(--border-color, #e0e0e0); +} + +.sharepointFolderList { + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 4px; + background: var(--bg-primary, white); +} + +.sharepointFolderItem { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + font-size: 0.875rem; + border-bottom: 1px solid var(--border-light, #f0f0f0); + transition: background-color 0.1s; +} + +.sharepointFolderItem:last-child { + border-bottom: none; +} + +.sharepointFolderItem:hover { + background: var(--bg-hover, #f5f5f5); +} + +.sharepointFolderItem .folderName { + flex: 1; + cursor: pointer; +} + +.sharepointFolderItem .folderName:hover { + text-decoration: underline; +} + +.selectFolderButton { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + background: var(--primary-color, #1976d2); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.sharepointFolderItem:hover .selectFolderButton { + opacity: 1; +} + +.selectFolderButton:hover { + background: var(--primary-hover, #1565c0); +} + +.sharepointEmpty { + padding: 1rem; + text-align: center; + color: var(--text-secondary, #666); + font-size: 0.875rem; + font-style: italic; +} + +.selectCurrentFolderButton { + width: 100%; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + background: var(--success-color, #2e7d32); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + transition: background-color 0.15s; +} + +.selectCurrentFolderButton:hover { + background: var(--success-hover, #1b5e20); +} + +.placeholderInput { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #333); +} + +.placeholderInput:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.placeholderSelect { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #333); + cursor: pointer; +} + +.placeholderSelect:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.placeholderSelect:disabled { + background: var(--bg-secondary, #f5f5f5); + cursor: not-allowed; +} + +.placeholderTextarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #333); + resize: vertical; + min-height: 60px; +} + +.placeholderTextarea:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.placeholderCheckbox { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-primary, #333); + cursor: pointer; +} + +.placeholderCheckbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary-color, #f25843); + cursor: pointer; +} + +.noPlaceholders { + padding: 1rem; + text-align: center; + color: var(--text-tertiary, #999); + font-size: 0.875rem; + background: var(--bg-secondary, #f5f5f5); + border-radius: 6px; +} + +/* Form Fields */ +.formFields { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.formLabel { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.formLabel .required { + color: var(--danger-color, #dc3545); +} + +.formInput { + padding: 0.625rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #333); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.formInput:focus { + outline: none; + border-color: var(--primary-color, #f25843); + box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1); +} + +.formTextarea { + padding: 0.625rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #333); + resize: vertical; + min-height: 80px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.formTextarea:focus { + outline: none; + border-color: var(--primary-color, #f25843); + box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1); +} + +.formHint { + font-size: 0.75rem; + color: var(--text-tertiary, #999); + margin: 0; +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); + cursor: pointer; +} + +.checkboxLabel input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary-color, #f25843); + cursor: pointer; +} + +/* Language Tabs */ +.languageTabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + padding-bottom: 0.5rem; +} + +.languageTab { + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid transparent; + border-bottom: none; + border-radius: 6px 6px 0 0; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary, #666); + cursor: pointer; + transition: all 0.2s; +} + +.languageTab:hover { + background: var(--bg-secondary, #f5f5f5); + color: var(--text-primary, #333); +} + +.languageTab.active { + background: var(--bg-primary, #ffffff); + border-color: var(--border-color, #e0e0e0); + color: var(--primary-color, #f25843); + border-bottom: 2px solid var(--primary-color, #f25843); + margin-bottom: -1px; +} + +/* Loading State */ +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: var(--text-secondary, #666); +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color, #e0e0e0); + border-top-color: var(--primary-color, #f25843); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive */ +@media (max-width: 1200px) { + .actionsPanel { + width: 350px; + } +} + +@media (max-width: 900px) { + .editorContent { + flex-direction: column; + } + + .formPanel { + border-right: none; + border-bottom: 1px solid var(--border-color, #e0e0e0); + } + + .actionsPanel { + width: 100%; + height: 300px; + } + + .actionsPanelCollapsed { + width: 100%; + height: 48px; + } + + .actionsPanelCollapsed .actionsPanelToggle { + writing-mode: horizontal-tb; + text-orientation: mixed; + padding: 0.75rem; + height: auto; + } + + .actionsPanelCollapsed .actionsPanelToggle svg { + margin-bottom: 0; + margin-right: 0.5rem; + transform: none; + } +} diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx new file mode 100644 index 0000000..677daea --- /dev/null +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -0,0 +1,1060 @@ +/** + * 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 { FaTimes, FaSave, FaChevronLeft, FaChevronRight, FaRocket, FaFileAlt, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa'; +import { ActionsPanel } from '../ActionsPanel'; +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" + /> + +
+ + {isExpanded && ( +
+ {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); + + // 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); + } + + // 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 + }; + } + + // 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, 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 ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+

{editorTitle}

+ + {mode === 'template' ? ( + <> Template + ) : ( + <> Definition + )} + +
+
+ +
+
+ + {/* 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 */} +
+ +