Merge pull request #30 from valueonag/feat/unify-automation
Feat/unify automation
This commit is contained in:
commit
5805c547eb
318 changed files with 52115 additions and 20577 deletions
182
docs/i18n-remaining-items.md
Normal file
182
docs/i18n-remaining-items.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# i18n — Verbleibende statische Texte
|
||||
|
||||
> Stand: 2026-04-08
|
||||
> Diese Stellen verwenden noch hardcoded Strings in Object-Literalen, Arrays oder Hook-Defaults.
|
||||
> Sie können nicht einfach mit `t()` gewrapped werden, da sie ausserhalb des React-Render-Kontexts definiert sind.
|
||||
> **Lösung:** Array/Object in die Komponente verschieben oder eine Factory-Funktion `(t) => [...]` nutzen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hook-Defaults (`useConfirm`, `usePrompt`)
|
||||
|
||||
Diese Defaults propagieren in die gesamte App. Ein Fix hier wirkt global.
|
||||
|
||||
| Datei | Zeile | Property | Text |
|
||||
|-------|-------|----------|------|
|
||||
| `hooks/useConfirm.tsx` | 26 | `title` | `'Bestätigung'` |
|
||||
| `hooks/useConfirm.tsx` | 27 | `confirmLabel` | `'Bestätigen'` |
|
||||
| `hooks/useConfirm.tsx` | 28 | `cancelLabel` | `'Abbrechen'` |
|
||||
| `hooks/usePrompt.tsx` | 29 | `title` | `'Eingabe'` |
|
||||
| `hooks/usePrompt.tsx` | 30 | `confirmLabel` | `'OK'` |
|
||||
| `hooks/usePrompt.tsx` | 31 | `cancelLabel` | `'Abbrechen'` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Monatsnamen
|
||||
|
||||
| Datei | Zeilen | Kontext |
|
||||
|-------|--------|---------|
|
||||
| `pages/billing/BillingDashboard.tsx` | 182–193 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
|
||||
| `components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx` | 536–541 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Tab-Labels (statische Arrays ausserhalb Komponente)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `pages/Settings.tsx` | 23–27 | `'Profil'`, `'Darstellung'`, `'Stimme & Sprache'`, `'Neutralisierung (lokal)'`, `'Datenschutz'` |
|
||||
| `pages/views/workspace/WorkspaceSettingsPage.tsx` | 16–17 | `'Generelle Einstellungen'`, `'Neutralisierung (Workspace)'` |
|
||||
| `pages/views/neutralization/NeutralizationView.tsx` | 744–745 | `'Configuration'`, `'Playground'` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Spalten-Definitionen (Column-Arrays)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `pages/admin/AdminLanguagesPage.tsx` | 29–32 | `'Code'`, `'Bezeichnung'`, `'Status'`, `'Einträge'` |
|
||||
| `pages/admin/AdminSubscriptionsPage.tsx` | 13–23 | `'Mandant'`, `'Plan'`, `'Status'`, `'Wiederkehrend'`, `'User'`, `'Instanzen'`, `'Revenue/Mt (CHF)'`, `'Gestartet'`, `'Periodenende'`, `'Preis/User'`, `'Preis/Instanz'` |
|
||||
| `pages/admin/AdminUserMandatesPage.tsx` | 104–144 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
|
||||
| `pages/admin/AdminMandateRolesPage.tsx` | 106–124 | `'Bezeichnung'`, `'Beschreibung'`, `'Geltungsbereich'` |
|
||||
| `pages/admin/AdminInvitationsPage.tsx` | 90–155 | `'Benutzername'`, `'E-Mail'`, `'Rollen'`, `'Gültig bis'`, `'Verwendet'`, `'Erstellt'` |
|
||||
| `pages/admin/AdminFeatureRolesPage.tsx` | 138–155 | `'Rollen-Label'`, `'Beschreibung'`, `'Feature'` |
|
||||
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 205–245 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
|
||||
| `pages/admin/AdminFeatureAccessPage.tsx` | 91–104 | `'Name'`, `'Feature'`, `'Aktiv'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 184–235 | `'Workflow'`, `'Aktiv'`, `'Läuft'`, `'Steht bei'`, `'Erstellt'`, `'Zuletzt gestartet'`, `'Läufe'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 174–202 | `'Vorlage'`, `'Scope'`, `'Freigegeben'`, `'Erstellt von'`, `'Erstellt'` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Formular-Feld-Definitionen (AttributeDefinition-Arrays)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `pages/Settings.tsx` | 56–58 | `'Vollstaendiger Name'`, `'E-Mail-Adresse'`, `'Sprache'` + descriptions + placeholders |
|
||||
| `pages/admin/wizards/FeatureInstanceWizard.tsx` | 75–78 | `'Mandant'`, `'Feature'`, `'Bezeichnung'`, `'Aktiv'` |
|
||||
| `pages/admin/InstanceDetailModal.tsx` | 186–279 | `'Benutzer'`, `'Rollen'`, `'Aktiv'`, `'Einstellungen'`, `'Bezeichnung'`, `'Aktiviert'` |
|
||||
| `pages/admin/AdminMandateRolesPage.tsx` | 165–171 | `'Geltungsbereich'`, `'Nur dieser Mandant'`, `'Template (wird bei neuen Mandanten kopiert)'` |
|
||||
| `pages/admin/AdminMandateRolePermissionsPage.tsx` | 219–221 | `'Mandanten-Rollen'`, `'Alle (inkl. Templates)'`, `'Nur Templates'` |
|
||||
| `pages/admin/AdminFeatureRolesPage.tsx` | 173–205 | `'Rollen-Label'`, `'Beschreibung'` + descriptions |
|
||||
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 272–299 | `'Benutzer'`, `'Rollen'`, `'Aktiv'` |
|
||||
| `pages/admin/AdminInvitationsPage.tsx` | 181 | `'Gültigkeitsdauer (Stunden)'` |
|
||||
| `pages/admin/AdminFeatureAccessPage.tsx` | 629–636 | `'Bezeichnung'`, `'Aktiviert'` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Status-/Option-Maps (Object-Literale)
|
||||
|
||||
| Datei | Zeilen | Kontext |
|
||||
|-------|--------|---------|
|
||||
| `pages/billing/SubscriptionTab.tsx` | 48–53 | Status-Map: `'Zahlung ausstehend'`, `'Geplant'`, `'Aktiv'`, `'Testphase'`, `'Abgelaufen'` |
|
||||
| `components/FlowEditor/editor/CanvasHeader.tsx` | 40–42 | Status-Map: `'Entwurf'`, `'Veröffentlicht'`, `'Archiviert'` |
|
||||
| `components/FlowEditor/editor/WorkflowConfigurationModal.tsx` | 17–20 | Trigger-Typen: `'Manueller Trigger'`, `'Formular'`, `'Zeitplan'`, `'Immer aktiv'` |
|
||||
| `components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx` | 22–49 | Schedule-Optionen: `'Täglich'`, `'Werktage'`, `'Bestimmte Tage'`, `'Intervall'`, `'Sekunden'`, `'Minuten'`, `'Stunden'`, `'Tage'`, `'Jahre'` |
|
||||
| `components/RbacExportImport/RbacExportImport.tsx` | 49–62 | Import-Modi: `'Zusammenführen'`, `'Nur hinzufügen'`, `'Ersetzen'` + descriptions |
|
||||
| `components/AccessRules/AccessRulesEditor.tsx` | 633–636 | Tab-Labels: `'Daten'`, `'Ressourcen'` |
|
||||
| `hooks/useAccessRules.tsx` | 23–26 | Scope-Labels: `'Keine'`, `'Eigene'`, `'Gruppe'`, `'Alle'` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Action-Button `title:`-Props (in Object-Literalen)
|
||||
|
||||
| Datei | Zeilen | Titles |
|
||||
|-------|--------|--------|
|
||||
| `pages/admin/AdminUsersPage.tsx` | 200–212 | `'Bearbeiten'`, `'Löschen'`, `'Passwort-Link senden'` |
|
||||
| `pages/admin/AdminUserMandatesPage.tsx` | 352–356 | `'Rollen bearbeiten'`, `'Aus Mandant entfernen'` |
|
||||
| `pages/admin/AdminMandatesPage.tsx` | 127–234 | `'Mandant deaktivieren'`, `'Deaktivieren'`, `'Hard Delete (irreversibel)'`, `'Endgültig löschen'`, `'Bearbeiten'`, `'Deaktivieren (Soft-Delete)'` |
|
||||
| `pages/admin/AdminMandateRolesPage.tsx` | 430–435 | `'Rolle bearbeiten'`, `'Rolle löschen'` |
|
||||
| `pages/admin/AdminInvitationsPage.tsx` | 354–362 | `'Einladung widerrufen'`, `'Einladungs-Link anzeigen'` |
|
||||
| `pages/admin/AdminFeatureRolesPage.tsx` | 372–384 | `'Rolle bearbeiten'`, `'Rolle löschen'`, `'Berechtigungen verwalten'` |
|
||||
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 535–539 | `'Rollen bearbeiten'`, `'Aus Instanz entfernen'` |
|
||||
| `pages/admin/AdminFeatureAccessPage.tsx` | 457–471 | `'Instanz löschen'`, `'Instanz bearbeiten'`, `'Rollen synchronisieren'` |
|
||||
| `pages/admin/PermissionMatrix.tsx` | 39 | `'Benutzer entfernen'` |
|
||||
| `pages/basedata/ConnectionsPage.tsx` | 324–347 | `'Bearbeiten'`, `'Löschen'`, `'Verbinden'`, `'Token erneuern'` |
|
||||
| `pages/basedata/FilesPage.tsx` | 232–458 | `'Neuer Ordner'`, `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'`, `'Vorschau'` |
|
||||
| `pages/basedata/PromptsPage.tsx` | 215–225 | `'Duplizieren'`, `'Bearbeiten'`, `'Löschen'` |
|
||||
| `pages/billing/AdminSubscriptionsPage.tsx` | 67 | `'Sofort kündigen'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 455–467 | `'Bearbeiten'`, `'Löschen'`, `'In Buchhaltung synchronisieren'` |
|
||||
| `pages/views/trustee/TrusteePositionDocumentsView.tsx` | 192–198 | `'Verknüpfung bearbeiten'`, `'Verknüpfung entfernen'` |
|
||||
| `pages/views/trustee/TrusteeDocumentsView.tsx` | 225–238 | `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'` |
|
||||
| `pages/views/realestate/RealEstateProjectsView.tsx` | 167–168 | `'Bearbeiten'`, `'Löschen'` |
|
||||
| `pages/views/realestate/RealEstateParcelsView.tsx` | 186–194 | `'Bearbeiten'`, `'Löschen'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 136–336 | `'Workflow umbenennen'`, `'Bearbeiten'`, `'Löschen'`, `'Umbenennen'`, `'Aktivieren'`, `'Deaktivieren'`, `'Ausführen'` |
|
||||
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 149–289 | `'Vorlage umbenennen'`, `'Im Editor öffnen'`, `'Löschen'`, `'Umbenennen'`, `'Als Workflow kopieren'`, `'Scope ändern'` |
|
||||
| `pages/views/chatbot/ChatbotConversationsView.tsx` | 88 | `'Konversation löschen'` |
|
||||
| `components/FlowEditor/editor/Automation2FlowEditor.tsx` | 239 | `'Workflow speichern'` |
|
||||
| `components/FolderTree/FolderTree.tsx` | 384, 768 | `'Neuer Ordner'` (prompt title) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Onboarding-Texte (Object-Literale)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `components/OnboardingAssistant.tsx` | 99–149 | `'Mandant einrichten'`, `'Erstes Feature aktivieren'`, `'Erste Datenquelle einbinden'`, `'Ersten AI-Chat starten'` |
|
||||
|
||||
---
|
||||
|
||||
## 9. ClickUp Node Config (Feld-Optionen)
|
||||
|
||||
| Datei | Zeilen | Labels |
|
||||
|-------|--------|--------|
|
||||
| `components/FlowEditor/nodes/configs/ClickUpNodeConfig.tsx` | 786–794 | `'Titel (name)'`, `'Beschreibung'`, `'Status'`, `'Priorität (1–4)'`, `'Fälligkeit (Datum oder ms)'`, `'Zeitschätzung (Stunden)'`, `'Zeitschätzung (ms)'`, `'Zugewiesene'`, `'Benutzerdefiniertes Feld'` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Sonstige Einzel-Stellen
|
||||
|
||||
| Datei | Zeile | Property | Text |
|
||||
|-------|-------|----------|------|
|
||||
| `pages/admin/ChatbotConfigSection.tsx` | 58 | `label` | `'Althaus Preprocessor'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 167 | `label` | `'Belege'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 232 | `label` | `'Sync-Status'` |
|
||||
| `pages/views/trustee/TrusteePositionsView.tsx` | 445 | `label` | `'Buchhaltung synchronisieren'` |
|
||||
| `pages/views/trustee/TrusteeExpenseImportView.tsx` | 361 | `label` | `'Daily at 22:00'` |
|
||||
| `pages/basedata/PromptsPage.tsx` | 81 | `label` | `'Created By'` |
|
||||
| `pages/basedata/FilesPage.tsx` | 152 | `label` | `'Created By'` |
|
||||
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 22 | `label` | `'Feld 1'` |
|
||||
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 116 | `label` | `'Neues Feld'` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Sprach-/Locale-Listen (Eigenname-Labels — evtl. NICHT übersetzen)
|
||||
|
||||
> Diese Listen enthalten Sprachnamen in der jeweiligen Sprache (Endonym). Sie werden typischerweise **nicht** übersetzt, da der User die Sprache in ihrer Originalbezeichnung erkennen soll.
|
||||
|
||||
| Datei | Zeilen | Kontext |
|
||||
|-------|--------|---------|
|
||||
| `pages/Settings.tsx` | 50–52, 563–565 | Fallback-Sprachoptionen: `'Deutsch'`, `'English'`, `'Français'` |
|
||||
| `pages/views/workspace/WorkspaceInput.tsx` | 16–27 | STT-Sprachliste (12 Sprachen) |
|
||||
| `pages/admin/AdminLanguagesPage.tsx` | 38–65 | Alle verfügbaren Sprach-Codes mit Endonymen |
|
||||
| `components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx` | 23–32 | Voice-Sprachliste mit Endonymen |
|
||||
| `components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx` | 672–675 | Fallback-Sprachoptionen |
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Kategorie | Anzahl Stellen | Dateien |
|
||||
|-----------|---------------|---------|
|
||||
| Hook-Defaults | 6 | 2 |
|
||||
| Monatsnamen | 24 | 2 |
|
||||
| Tab-Labels | 9 | 3 |
|
||||
| Spalten-Definitionen | ~55 | 10 |
|
||||
| Formular-Felder | ~30 | 9 |
|
||||
| Status-/Option-Maps | ~30 | 7 |
|
||||
| Action-Button titles | ~65 | 23 |
|
||||
| Onboarding-Texte | 4 | 1 |
|
||||
| ClickUp-Felder | 9 | 1 |
|
||||
| Sonstige | 9 | 5 |
|
||||
| Sprach-Listen (evtl. nicht übersetzen) | ~60 | 5 |
|
||||
| **Total (ohne Sprach-Listen)** | **~241** | **~45 Dateien** |
|
||||
19322
scripts/i18n_missing_report.json
Normal file
19322
scripts/i18n_missing_report.json
Normal file
File diff suppressed because it is too large
Load diff
15027
scripts/i18n_missing_report.md
Normal file
15027
scripts/i18n_missing_report.md
Normal file
File diff suppressed because it is too large
Load diff
17
scripts/list_t_errors.py
Normal file
17
scripts/list_t_errors.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
text = Path(__file__).resolve().parent.parent / "tsc-out.txt"
|
||||
content = text.read_text(encoding="utf-8")
|
||||
files = sorted(
|
||||
{
|
||||
m.group(1)
|
||||
for line in content.splitlines()
|
||||
if "Cannot find name 't'" in line
|
||||
for m in [re.match(r"^(src/[^\(:]+\.tsx)", line)]
|
||||
if m
|
||||
}
|
||||
)
|
||||
for f in files:
|
||||
print(f)
|
||||
print("TOTAL", len(files), file=__import__("sys").stderr)
|
||||
19
src/App.tsx
19
src/App.tsx
|
|
@ -37,11 +37,13 @@ import { DashboardPage } from './pages/Dashboard';
|
|||
import { SettingsPage } from './pages/Settings';
|
||||
import { GDPRPage } from './pages/GDPR';
|
||||
import StorePage from './pages/Store';
|
||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminAutomationLogsPage, AdminLogsPage } from './pages/admin';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
|
|
@ -96,6 +98,7 @@ function App() {
|
|||
|
||||
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||
<Route path="store" element={<StorePage />} />
|
||||
<Route path="integrations" element={<IntegrationsOverviewPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="gdpr" element={<GDPRPage />} />
|
||||
|
||||
|
|
@ -114,8 +117,14 @@ function App() {
|
|||
<Route path="billing">
|
||||
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
||||
<Route path="transactions" element={<BillingDataView />} />
|
||||
<Route path="admin" element={<BillingAdmin />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* AUTOMATIONS DASHBOARD */}
|
||||
{/* ============================================== */}
|
||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
||||
|
||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||
|
|
@ -148,6 +157,8 @@ function App() {
|
|||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
||||
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
||||
|
||||
{/* Automation Feature Views */}
|
||||
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
||||
|
|
@ -191,13 +202,13 @@ function App() {
|
|||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||
<Route path="billing">
|
||||
<Route index element={<BillingAdmin />} />
|
||||
<Route index element={<Navigate to="/billing/admin" replace />} />
|
||||
<Route path="mandates" element={<BillingMandateView />} />
|
||||
</Route>
|
||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||
<Route path="automation-logs" element={<AdminAutomationLogsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="languages" element={null} />
|
||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// api.ts
|
||||
import axios from 'axios';
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
|
||||
import { clearUserDataCache } from './utils/userCache';
|
||||
import { clearUserDataCache, getUserDataCache } from './utils/userCache';
|
||||
|
||||
// Utility function to resolve hostname to IP address
|
||||
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
|
||||
|
|
@ -85,6 +85,13 @@ api.interceptors.request.use(
|
|||
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
||||
}
|
||||
|
||||
// Send app language to backend so i18n labels match the UI
|
||||
const userData = getUserDataCache();
|
||||
const appLanguage = userData?.language || navigator.language.split('-')[0] || 'de';
|
||||
if (config.headers) {
|
||||
config.headers['Accept-Language'] = appLanguage;
|
||||
}
|
||||
|
||||
// Add multi-tenant context headers from URL (if not already set)
|
||||
// This ensures Feature-Instance roles are loaded for permission checks
|
||||
const context = getContextFromUrl();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface AttributeDefinition {
|
|||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
|
||||
options?: Array<{ value: string | number; label: string }> | string;
|
||||
validation?: any;
|
||||
ui?: any;
|
||||
readonly?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,532 +0,0 @@
|
|||
/**
|
||||
* Automation2 API
|
||||
* Node types and graph execution for n8n-style flows.
|
||||
*/
|
||||
|
||||
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface NodeTypeParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
export interface NodeType {
|
||||
id: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: NodeTypeParameter[];
|
||||
inputs: number;
|
||||
outputs: number;
|
||||
/** Labels per output (e.g. ["Ja", "Nein"] for flow.ifElse) */
|
||||
outputLabels?: string[];
|
||||
executor: string;
|
||||
meta?: {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
method?: string;
|
||||
action?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeTypeCategory {
|
||||
id: string;
|
||||
label: Record<string, string> | string;
|
||||
}
|
||||
|
||||
export interface NodeTypesResponse {
|
||||
nodeTypes: NodeType[];
|
||||
categories: NodeTypeCategory[];
|
||||
}
|
||||
|
||||
export interface Automation2GraphNode {
|
||||
id: string;
|
||||
type: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Automation2Connection {
|
||||
source: string;
|
||||
target: string;
|
||||
sourceOutput?: number;
|
||||
targetInput?: number;
|
||||
}
|
||||
|
||||
export interface Automation2Graph {
|
||||
nodes: Automation2GraphNode[];
|
||||
connections: Automation2Connection[];
|
||||
}
|
||||
|
||||
export interface ExecuteGraphResponse {
|
||||
success: boolean;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
error?: string;
|
||||
stopped?: boolean;
|
||||
failedNode?: string;
|
||||
paused?: boolean;
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
/** Entry point / start configured outside the canvas (manual, form, schedule, …) */
|
||||
export interface WorkflowEntryPoint {
|
||||
id: string;
|
||||
kind: string;
|
||||
category: 'on_demand' | 'always_on';
|
||||
enabled: boolean;
|
||||
title: Record<string, string> | string;
|
||||
description?: Record<string, string>;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Automation2Workflow {
|
||||
id: string;
|
||||
label: string;
|
||||
graph: Automation2Graph;
|
||||
active?: boolean;
|
||||
/** Entry points (Starts) — how this workflow may be invoked */
|
||||
invocations?: WorkflowEntryPoint[];
|
||||
/** Enriched: run count */
|
||||
runCount?: number;
|
||||
/** Enriched: has active (running/paused) run */
|
||||
isRunning?: boolean;
|
||||
/** Enriched: status of active run */
|
||||
runStatus?: string;
|
||||
/** Enriched: nodeId where workflow is stuck (paused) */
|
||||
stuckAtNodeId?: string;
|
||||
/** Enriched: human-readable label for stuck node */
|
||||
stuckAtNodeLabel?: string;
|
||||
/** Enriched: created timestamp (seconds) */
|
||||
createdAt?: number;
|
||||
/** Enriched: last run started timestamp (seconds) */
|
||||
lastStartedAt?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
/**
|
||||
* Fetch node types for the flow builder (backend-driven).
|
||||
* GET /api/automation2/{instanceId}/node-types?language=de
|
||||
*/
|
||||
export async function fetchNodeTypes(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
language = 'de'
|
||||
): Promise<NodeTypesResponse> {
|
||||
console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`);
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/node-types`,
|
||||
method: 'get',
|
||||
params: { language },
|
||||
});
|
||||
const nodeTypes = data?.nodeTypes ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
|
||||
return { nodeTypes, categories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automation2 graph.
|
||||
* POST /api/automation2/{instanceId}/execute
|
||||
*/
|
||||
export interface ExecuteGraphOptions {
|
||||
/** Use a configured start on the saved workflow */
|
||||
entryPointId?: string;
|
||||
/** Full run envelope (overrides entry point mapping) */
|
||||
runEnvelope?: Record<string, unknown>;
|
||||
/** Merged into envelope.payload */
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function executeGraph(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
graph: Automation2Graph,
|
||||
workflowId?: string,
|
||||
options?: ExecuteGraphOptions
|
||||
): Promise<ExecuteGraphResponse> {
|
||||
console.log(
|
||||
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
|
||||
{ nodes: graph.nodes, connections: graph.connections, options }
|
||||
);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const data: Record<string, unknown> = { graph, workflowId };
|
||||
if (options?.entryPointId) data.entryPointId = options.entryPointId;
|
||||
if (options?.runEnvelope) data.runEnvelope = options.runEnvelope;
|
||||
if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload;
|
||||
const result = await request({
|
||||
url: `/api/automation2/${instanceId}/execute`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
const ms = Math.round(performance.now() - start);
|
||||
console.log(
|
||||
`${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`,
|
||||
result
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const ms = Math.round(performance.now() - start);
|
||||
console.error(
|
||||
`${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`,
|
||||
err
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Workflows CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export async function fetchWorkflows(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: { active?: boolean }
|
||||
): Promise<Automation2Workflow[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/workflows`,
|
||||
method: 'get',
|
||||
params: params?.active !== undefined ? { active: params.active } : undefined,
|
||||
});
|
||||
return data?.workflows ?? [];
|
||||
}
|
||||
|
||||
export async function fetchWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/workflows`,
|
||||
method: 'post',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string,
|
||||
body: {
|
||||
label?: string;
|
||||
graph?: Automation2Graph;
|
||||
invocations?: WorkflowEntryPoint[];
|
||||
active?: boolean;
|
||||
}
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'put',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export interface Automation2Run {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
status: string;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
currentNodeId?: string;
|
||||
}
|
||||
|
||||
export async function fetchWorkflowRuns(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<Automation2Run[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}/runs`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.runs ?? [];
|
||||
}
|
||||
|
||||
export interface CompletedRun extends Automation2Run {
|
||||
workflowLabel?: string;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedAt?: number;
|
||||
}
|
||||
|
||||
export async function fetchCompletedRuns(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
limit = 20
|
||||
): Promise<CompletedRun[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/runs/completed`,
|
||||
method: 'get',
|
||||
params: { limit },
|
||||
});
|
||||
return data?.runs ?? [];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface Automation2Task {
|
||||
id: string;
|
||||
runId: string;
|
||||
workflowId: string;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
result?: Record<string, unknown>;
|
||||
/** Workflow label (enriched by API) */
|
||||
workflowLabel?: string;
|
||||
/** Unix timestamp ms (from sysCreatedAt) */
|
||||
createdAt?: number;
|
||||
/** Optional due date - configurable in future */
|
||||
dueAt?: number;
|
||||
}
|
||||
|
||||
export async function fetchTasks(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: { workflowId?: string; status?: string }
|
||||
): Promise<Automation2Task[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/tasks`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
return data?.tasks ?? [];
|
||||
}
|
||||
|
||||
export async function completeTask(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
taskId: string,
|
||||
result: Record<string, unknown>
|
||||
): Promise<ExecuteGraphResponse> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/tasks/${taskId}/complete`,
|
||||
method: 'post',
|
||||
data: { result },
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Connections and Browse (for Email/SharePoint node config)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface UserConnection {
|
||||
id: string;
|
||||
authority: string;
|
||||
externalUsername?: string;
|
||||
externalEmail?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export async function fetchConnections(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string
|
||||
): Promise<UserConnection[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/connections`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.connections ?? [];
|
||||
}
|
||||
|
||||
export interface ConnectionService {
|
||||
service: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export async function fetchConnectionServices(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
connectionId: string
|
||||
): Promise<ConnectionService[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/connections/${connectionId}/services`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.services ?? [];
|
||||
}
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isFolder: boolean;
|
||||
size?: number;
|
||||
mimeType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchBrowse(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
connectionId: string,
|
||||
service: string,
|
||||
path = '/'
|
||||
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/connections/${connectionId}/browse`,
|
||||
method: 'get',
|
||||
params: { service, path },
|
||||
});
|
||||
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
|
||||
}
|
||||
|
||||
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
|
||||
export async function fetchClickupTask(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
taskId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
|
||||
export async function fetchClickupList(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/lists/${listId}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
|
||||
export async function fetchClickupTeam(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
teamId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/teams/${teamId}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
|
||||
export async function fetchClickupListFields(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/lists/${listId}/fields`,
|
||||
method: 'get',
|
||||
});
|
||||
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
|
||||
export interface ClickupListTaskItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function fetchClickupListTasks(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string,
|
||||
options?: { page?: number; includeClosed?: boolean }
|
||||
): Promise<
|
||||
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
|
||||
> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
|
||||
method: 'get',
|
||||
params: {
|
||||
page: options?.page ?? 0,
|
||||
include_closed: options?.includeClosed ?? false,
|
||||
},
|
||||
});
|
||||
return (data && typeof data === 'object' ? data : {}) as {
|
||||
tasks?: ClickupListTaskItem[];
|
||||
last_page?: boolean;
|
||||
} & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */
|
||||
export async function loadClickupListTasksForDropdown(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const acc: Array<{ id: string; name: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const maxPages = 12;
|
||||
const pageSizeHint = 100;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const data = await fetchClickupListTasks(request, connectionId, listId, {
|
||||
page,
|
||||
includeClosed: false,
|
||||
});
|
||||
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
|
||||
const err = (data as { error?: unknown }).error;
|
||||
const body = (data as { body?: string }).body;
|
||||
throw new Error(
|
||||
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
|
||||
);
|
||||
}
|
||||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
for (const t of tasks) {
|
||||
const id = t?.id != null ? String(t.id) : '';
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
acc.push({ id, name: String(t.name ?? id) });
|
||||
}
|
||||
const rawLast = (data as Record<string, unknown>).last_page;
|
||||
const last =
|
||||
rawLast === true ||
|
||||
rawLast === 'true' ||
|
||||
tasks.length === 0 ||
|
||||
tasks.length < pageSizeHint;
|
||||
if (last) break;
|
||||
}
|
||||
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
return acc;
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface Automation {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
featureInstanceId: string;
|
||||
label: string;
|
||||
template: string | object;
|
||||
placeholders: Record<string, string>;
|
||||
schedule: string;
|
||||
active: boolean;
|
||||
status?: string;
|
||||
lastExecution?: number;
|
||||
nextExecution?: number;
|
||||
executionLogs?: AutomationLog[];
|
||||
allowedProviders?: string[];
|
||||
sysCreatedAt?: number;
|
||||
_updatedAt?: number;
|
||||
sysCreatedByUserName?: string;
|
||||
mandateName?: string;
|
||||
featureInstanceName?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AutomationLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
status: string;
|
||||
workflowId?: string;
|
||||
messages?: string[];
|
||||
}
|
||||
|
||||
// Multilingual text type (matches backend TextMultilingual)
|
||||
export interface TextMultilingual {
|
||||
en: string;
|
||||
ge?: string;
|
||||
fr?: string;
|
||||
it?: string;
|
||||
}
|
||||
|
||||
// AutomationTemplate from DB
|
||||
export interface AutomationTemplate {
|
||||
id: string;
|
||||
label: TextMultilingual;
|
||||
overview?: TextMultilingual;
|
||||
template: string; // JSON string with {{KEY:...}} placeholders
|
||||
sysCreatedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysCreatedByUserName?: string;
|
||||
}
|
||||
|
||||
// Workflow action definition from backend
|
||||
export interface WorkflowAction {
|
||||
method: string;
|
||||
action: string;
|
||||
actionId: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
parameters: WorkflowActionParameter[];
|
||||
exampleJson: {
|
||||
execMethod: string;
|
||||
execAction: string;
|
||||
execParameters: Record<string, any>;
|
||||
execResultLabel: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowActionParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
frontendType: string;
|
||||
required: boolean;
|
||||
default?: any;
|
||||
description: string;
|
||||
frontendOptions?: string | string[];
|
||||
}
|
||||
|
||||
export interface CreateAutomationRequest {
|
||||
label: string;
|
||||
template: string;
|
||||
placeholders?: Record<string, string>;
|
||||
schedule?: string;
|
||||
active?: boolean;
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAutomationRequest {
|
||||
label?: string;
|
||||
template?: string;
|
||||
placeholders?: Record<string, string>;
|
||||
schedule?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecuteAutomationResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
workflowId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all automations for the current mandate
|
||||
* Endpoint: GET /api/automations
|
||||
*/
|
||||
export async function fetchAutomations(request: ApiRequestFunction): Promise<Automation[]> {
|
||||
console.log('📤 fetchAutomations: Making API request to /api/automations');
|
||||
|
||||
try {
|
||||
const data = await request({
|
||||
url: '/api/automations',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
console.log('📥 fetchAutomations: API response:', data);
|
||||
|
||||
// Handle different response formats
|
||||
let automations: Automation[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
automations = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
if (Array.isArray(data.automations)) {
|
||||
automations = data.automations;
|
||||
} else if (Array.isArray(data.items)) {
|
||||
automations = data.items;
|
||||
} else if (Array.isArray(data.data)) {
|
||||
automations = data.data;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ fetchAutomations: Returning ${automations.length} automations`);
|
||||
return automations;
|
||||
} catch (error) {
|
||||
console.error('❌ fetchAutomations: Error fetching automations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single automation by ID
|
||||
* Endpoint: GET /api/automations/{automationId}
|
||||
*/
|
||||
export async function fetchAutomation(
|
||||
request: ApiRequestFunction,
|
||||
automationId: string
|
||||
): Promise<Automation> {
|
||||
return await request({
|
||||
url: `/api/automations/${automationId}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new automation
|
||||
* Endpoint: POST /api/automations
|
||||
*/
|
||||
export async function createAutomationApi(
|
||||
request: ApiRequestFunction,
|
||||
automationData: CreateAutomationRequest
|
||||
): Promise<Automation> {
|
||||
return await request({
|
||||
url: '/api/automations',
|
||||
method: 'post',
|
||||
data: automationData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing automation
|
||||
* Endpoint: PUT /api/automations/{automationId}
|
||||
*/
|
||||
export async function updateAutomationApi(
|
||||
request: ApiRequestFunction,
|
||||
automationId: string,
|
||||
updateData: UpdateAutomationRequest
|
||||
): Promise<Automation> {
|
||||
return await request({
|
||||
url: `/api/automations/${automationId}`,
|
||||
method: 'put',
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an automation
|
||||
* Endpoint: DELETE /api/automations/{automationId}
|
||||
*/
|
||||
export async function deleteAutomationApi(
|
||||
request: ApiRequestFunction,
|
||||
automationId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/automations/${automationId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automation (test mode)
|
||||
* Endpoint: POST /api/automations/{automationId}/execute
|
||||
*/
|
||||
export async function executeAutomationApi(
|
||||
request: ApiRequestFunction,
|
||||
automationId: string
|
||||
): Promise<ExecuteAutomationResponse> {
|
||||
return await request({
|
||||
url: `/api/automations/${automationId}/execute`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch automation attributes for dynamic form generation
|
||||
* Endpoint: GET /api/attributes/AutomationDefinition
|
||||
*/
|
||||
export async function fetchAutomationAttributes(
|
||||
request: ApiRequestFunction
|
||||
): Promise<any[]> {
|
||||
const data = await request({
|
||||
url: '/api/attributes/AutomationDefinition',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
if (data?.attributes && Array.isArray(data.attributes)) {
|
||||
return data.attributes;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTOMATION TEMPLATES API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all automation templates (RBAC-filtered: own templates)
|
||||
* Endpoint: GET /api/automation-templates
|
||||
*/
|
||||
export async function fetchAutomationTemplates(
|
||||
request: ApiRequestFunction,
|
||||
params?: any
|
||||
): Promise<any> {
|
||||
const requestParams: Record<string, string> = {};
|
||||
if (params && typeof params === 'object') {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
return await request({
|
||||
url: '/api/automation-templates',
|
||||
method: 'get',
|
||||
params: requestParams,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single automation template by ID
|
||||
* Endpoint: GET /api/automation-templates/{templateId}
|
||||
*/
|
||||
export async function fetchAutomationTemplateById(
|
||||
request: ApiRequestFunction,
|
||||
templateId: string
|
||||
): Promise<AutomationTemplate | null> {
|
||||
try {
|
||||
return await request({
|
||||
url: `/api/automation-templates/${templateId}`,
|
||||
method: 'get'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching template:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new automation template
|
||||
* Endpoint: POST /api/automation-templates
|
||||
*/
|
||||
export async function createAutomationTemplateApi(
|
||||
request: ApiRequestFunction,
|
||||
templateData: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>
|
||||
): Promise<AutomationTemplate> {
|
||||
return await request({
|
||||
url: '/api/automation-templates',
|
||||
method: 'post',
|
||||
data: templateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update automation template
|
||||
* Endpoint: PUT /api/automation-templates/{templateId}
|
||||
*/
|
||||
export async function updateAutomationTemplateApi(
|
||||
request: ApiRequestFunction,
|
||||
templateId: string,
|
||||
templateData: Partial<AutomationTemplate>
|
||||
): Promise<AutomationTemplate> {
|
||||
return await request({
|
||||
url: `/api/automation-templates/${templateId}`,
|
||||
method: 'put',
|
||||
data: templateData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete automation template
|
||||
* Endpoint: DELETE /api/automation-templates/{templateId}
|
||||
*/
|
||||
export async function deleteAutomationTemplateApi(
|
||||
request: ApiRequestFunction,
|
||||
templateId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/automation-templates/${templateId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch automation template attributes for dynamic form generation
|
||||
* Endpoint: GET /api/automation-templates/attributes
|
||||
*/
|
||||
export async function fetchAutomationTemplateAttributes(
|
||||
request: ApiRequestFunction
|
||||
): Promise<any[]> {
|
||||
const data = await request({
|
||||
url: '/api/automation-templates/attributes',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
// Backend returns: { attributes: { model: "...", attributes: [...] } }
|
||||
if (data?.attributes?.attributes && Array.isArray(data.attributes.attributes)) {
|
||||
return data.attributes.attributes;
|
||||
}
|
||||
|
||||
// Fallback: direct attributes array
|
||||
if (data?.attributes && Array.isArray(data.attributes)) {
|
||||
return data.attributes;
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKFLOW ACTIONS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch available workflow actions (RBAC-filtered)
|
||||
* Endpoint: GET /api/automations/actions
|
||||
*/
|
||||
export async function fetchWorkflowActions(
|
||||
request: ApiRequestFunction
|
||||
): Promise<WorkflowAction[]> {
|
||||
const data = await request({
|
||||
url: '/api/automations/actions',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return data?.actions || [];
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
|||
features: [
|
||||
{
|
||||
code: 'trustee',
|
||||
label: { de: 'Treuhand', en: 'Trustee' },
|
||||
label: 'Treuhand',
|
||||
icon: 'briefcase',
|
||||
instances: [
|
||||
{
|
||||
|
|
@ -101,7 +101,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
|||
},
|
||||
{
|
||||
code: 'chatworkflow',
|
||||
label: { de: 'Workflow', en: 'Workflow' },
|
||||
label: 'Workflow',
|
||||
icon: 'play_circle',
|
||||
instances: [
|
||||
{
|
||||
|
|
@ -124,7 +124,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
|||
features: [
|
||||
{
|
||||
code: 'trustee',
|
||||
label: { de: 'Treuhand', en: 'Trustee' },
|
||||
label: 'Treuhand',
|
||||
icon: 'briefcase',
|
||||
instances: [
|
||||
{
|
||||
|
|
@ -234,9 +234,9 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
|||
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
|
||||
if (USE_MOCK) {
|
||||
return [
|
||||
{ code: 'trustee', label: { de: 'Treuhand', en: 'Trustee' }, icon: 'briefcase', instances: [] },
|
||||
{ code: 'chatworkflow', label: { de: 'Workflow', en: 'Workflow' }, icon: 'play_circle', instances: [] },
|
||||
{ code: 'chatbot', label: { de: 'Chatbot', en: 'Chatbot' }, icon: 'chat', instances: [] },
|
||||
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
||||
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
||||
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ export interface FolderInfo {
|
|||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
fileCount?: number;
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
createdAt?: number;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export interface Prompt {
|
|||
|
||||
export interface AttributeOption {
|
||||
value: string | number;
|
||||
label: string | { [key: string]: string };
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AttributeDefinition {
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ export interface StoreFeatureInstance {
|
|||
|
||||
export interface StoreFeature {
|
||||
featureCode: string;
|
||||
label: Record<string, string>;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: Record<string, string>;
|
||||
description: string;
|
||||
instances: StoreFeatureInstance[];
|
||||
canActivate: boolean;
|
||||
}
|
||||
|
|
@ -49,7 +49,9 @@ export interface SubscriptionInfo {
|
|||
status: string | null;
|
||||
maxDataVolumeMB: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
includedModules: number;
|
||||
budgetAiCHF: number | null;
|
||||
budgetAiPerUserCHF: number | null;
|
||||
currentFeatureInstances: number;
|
||||
trialEndsAt: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE';
|
|||
export interface SubscriptionPlan {
|
||||
planKey: string;
|
||||
selectableByUser: boolean;
|
||||
title: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
title: string;
|
||||
description: string;
|
||||
currency: string;
|
||||
billingPeriod: BillingPeriod;
|
||||
pricePerUserCHF: number;
|
||||
|
|
@ -19,8 +19,10 @@ export interface SubscriptionPlan {
|
|||
autoRenew: boolean;
|
||||
maxUsers: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
includedModules: number;
|
||||
maxDataVolumeMB?: number | null;
|
||||
budgetAiCHF?: number;
|
||||
budgetAiPerUserCHF?: number;
|
||||
trialDays: number | null;
|
||||
successorPlanKey: string | null;
|
||||
}
|
||||
|
|
@ -42,11 +44,20 @@ export interface MandateSubscription {
|
|||
stripeSubscriptionId: string | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionUsage {
|
||||
activeUsers: number;
|
||||
activeInstances: number;
|
||||
usedStorageMB: number;
|
||||
maxStorageMB: number | null;
|
||||
storagePercent: number | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionStatusResponse {
|
||||
active: boolean;
|
||||
subscription: MandateSubscription | null;
|
||||
plan: SubscriptionPlan | null;
|
||||
scheduled: MandateSubscription | null;
|
||||
usage: SubscriptionUsage | null;
|
||||
}
|
||||
|
||||
export interface ActivatePlanResponse {
|
||||
|
|
|
|||
|
|
@ -107,10 +107,10 @@ export interface TrusteePosition {
|
|||
|
||||
export interface AccountingConnectorInfo {
|
||||
connectorType: string;
|
||||
label: Record<string, string>;
|
||||
label: string;
|
||||
configFields: Array<{
|
||||
key: string;
|
||||
label: Record<string, string>;
|
||||
label: string;
|
||||
fieldType: string;
|
||||
secret: boolean;
|
||||
required: boolean;
|
||||
|
|
@ -751,6 +751,41 @@ export async function deletePositionDocument(
|
|||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUICK ACTIONS API
|
||||
// ============================================================================
|
||||
|
||||
export interface QuickActionResponse {
|
||||
actions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
category: string;
|
||||
actionType: 'agentPrompt' | 'workflow' | 'link';
|
||||
config: Record<string, any>;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
categories: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function fetchQuickActions(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
language: string = 'de'
|
||||
): Promise<QuickActionResponse> {
|
||||
return await request({
|
||||
url: `${_getTrusteeBaseUrl(instanceId)}/quick-actions`,
|
||||
method: 'get',
|
||||
params: { language }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACCOUNTING API
|
||||
// ============================================================================
|
||||
|
|
@ -838,3 +873,17 @@ export async function fetchSyncStatus(
|
|||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportAccountingData(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string
|
||||
): Promise<void> {
|
||||
const url = `${_getTrusteeBaseUrl(instanceId)}/accounting/export-data`;
|
||||
const response = await request({ url, method: 'get' });
|
||||
const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `trustee_data_${instanceId.slice(0, 8)}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export interface AttributeDefinition {
|
|||
description?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
|
||||
options?: Array<{ value: string | number; label: string }> | string;
|
||||
validation?: any;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,8 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ACCESS_LEVEL_OPTIONS, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules';
|
||||
import { _getAccessLevelOptions, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './AccessRules.module.css';
|
||||
|
||||
interface AccessLevelSelectProps {
|
||||
|
|
@ -25,6 +26,8 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
|
|||
showLabel = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const accessLevelOptions = _getAccessLevelOptions(t);
|
||||
const currentColor = getAccessLevelColor(value);
|
||||
|
||||
return (
|
||||
|
|
@ -42,7 +45,7 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
|
|||
color: currentColor,
|
||||
}}
|
||||
>
|
||||
{ACCESS_LEVEL_OPTIONS.map(option => (
|
||||
{accessLevelOptions.map(option => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import { AccessLevelSelect } from './AccessLevelSelect';
|
|||
import { AccessRulesTable } from './AccessRulesTable';
|
||||
import styles from './AccessRules.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
|
@ -66,6 +68,7 @@ interface RuleCardProps {
|
|||
}
|
||||
|
||||
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
|
||||
const { t } = useLanguage();
|
||||
const isDataRule = rule.context === 'DATA';
|
||||
|
||||
return (
|
||||
|
|
@ -83,7 +86,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
|||
<button
|
||||
className={`${styles.iconButton} ${styles.danger}`}
|
||||
onClick={() => onDelete(rule.id)}
|
||||
title="Regel löschen"
|
||||
title={t('Regel löschen')}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
|
|
@ -94,7 +97,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
|||
<div className={styles.permissionsGrid}>
|
||||
{/* View Toggle */}
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>View</span>
|
||||
<span className={styles.permissionLabel}>{t('Ansicht')}</span>
|
||||
<div className={styles.viewToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -110,7 +113,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
|||
{isDataRule ? (
|
||||
<>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Read</span>
|
||||
<span className={styles.permissionLabel}>{t('Lesen')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.read}
|
||||
onChange={(value) => onUpdate(rule.id, { read: value })}
|
||||
|
|
@ -119,7 +122,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
|||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Create</span>
|
||||
<span className={styles.permissionLabel}>{t('Erstellen')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.create}
|
||||
onChange={(value) => onUpdate(rule.id, { create: value })}
|
||||
|
|
@ -128,7 +131,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
|||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Update</span>
|
||||
<span className={styles.permissionLabel}>{t('Bearbeiten')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.update}
|
||||
onChange={(value) => onUpdate(rule.id, { update: value })}
|
||||
|
|
@ -137,7 +140,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
|||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Delete</span>
|
||||
<span className={styles.permissionLabel}>{t('Löschen')}</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.delete}
|
||||
onChange={(value) => onUpdate(rule.id, { delete: value })}
|
||||
|
|
@ -167,6 +170,7 @@ interface AddRuleFormProps {
|
|||
}
|
||||
|
||||
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
|
||||
const { t } = useLanguage();
|
||||
const [item, setItem] = useState('');
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [view, setView] = useState(true);
|
||||
|
|
@ -210,20 +214,20 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
|||
};
|
||||
|
||||
const getLabel = (obj: CatalogObject): string => {
|
||||
return obj.label.de || obj.label.en || obj.objectKey;
|
||||
return (typeof obj.label === 'string' ? obj.label : '') || obj.objectKey;
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.objectSelectorLabel}>
|
||||
<label className={styles.formLabel}>Objekt auswählen</label>
|
||||
<label className={styles.formLabel}>{t('select object')}</label>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleCustomButton}
|
||||
onClick={() => setUseCustom(!useCustom)}
|
||||
>
|
||||
{useCustom ? '← Aus Katalog wählen' : 'Freie Eingabe →'}
|
||||
{useCustom ? t('select from catalog') : t('free input')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -242,7 +246,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
|||
onChange={(e) => setItem(e.target.value)}
|
||||
className={styles.formSelect}
|
||||
>
|
||||
<option value="">-- Global (alle Objekte) --</option>
|
||||
<option value="">{t('global all objects')}</option>
|
||||
{Object.entries(groupedObjects).map(([feature, objs]) => (
|
||||
<optgroup key={feature} label={feature.toUpperCase()}>
|
||||
{objs.map(obj => (
|
||||
|
|
@ -256,7 +260,9 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
|||
)}
|
||||
|
||||
<span className={styles.formHint}>
|
||||
Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).
|
||||
{t(
|
||||
'Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -268,7 +274,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
|||
onChange={(e) => setView(e.target.checked)}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
Sichtbar (View)
|
||||
{t('Sichtbar (Ansicht)')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
|
@ -277,16 +283,21 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
|||
{/* Header Row */}
|
||||
<div className={styles.matrixHeader}>
|
||||
<div className={styles.matrixLabel}></div>
|
||||
<div className={styles.matrixGroup}>Eigene (m)</div>
|
||||
<div className={styles.matrixGroup}>Gruppe (g)</div>
|
||||
<div className={styles.matrixGroup}>Alle (a)</div>
|
||||
<div className={styles.matrixGroup}>{t('own')}</div>
|
||||
<div className={styles.matrixGroup}>{t('group')}</div>
|
||||
<div className={styles.matrixGroup}>{t('Alle')}</div>
|
||||
</div>
|
||||
|
||||
{/* CRUD Rows */}
|
||||
{(['create', 'read', 'update', 'delete'] as const).map(op => {
|
||||
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
|
||||
const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead;
|
||||
const labels = { create: 'Create', read: 'Read', update: 'Update', delete: 'Delete' };
|
||||
const labels = {
|
||||
create: t('Erstellen'),
|
||||
read: t('Lesen'),
|
||||
update: t('Bearbeiten'),
|
||||
delete: t('Löschen'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={op} className={styles.matrixRow}>
|
||||
|
|
@ -306,7 +317,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
|||
setValue(hierarchy[idx - 1] || 'n');
|
||||
}
|
||||
}}
|
||||
title={`${labels[op]} - ${level === 'm' ? 'Eigene' : level === 'g' ? 'Gruppe' : 'Alle'}`}
|
||||
title={`${labels[op]} - ${level === 'm' ? t('Eigene') : level === 'g' ? t('Gruppe') : t('Alle')}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -318,10 +329,10 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
|||
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
|
||||
Abbrechen
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button type="submit" className={styles.primaryButton}>
|
||||
<FaPlus /> Hinzufügen
|
||||
<FaPlus /> {t('Hinzufügen')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -351,6 +362,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
onDelete,
|
||||
onAdd,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
|
||||
|
||||
|
|
@ -369,9 +381,12 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
|
||||
const getEmptyText = () => {
|
||||
switch (context) {
|
||||
case 'DATA': return 'Keine Daten-Regeln definiert';
|
||||
case 'UI': return 'Keine UI-Regeln definiert';
|
||||
case 'RESOURCE': return 'Keine Ressourcen-Regeln definiert';
|
||||
case 'DATA':
|
||||
return t('Keine Daten-Regeln definiert');
|
||||
case 'UI':
|
||||
return t('Keine UI-Regeln definiert');
|
||||
case 'RESOURCE':
|
||||
return t('Keine Ressourcen-Regeln definiert');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -380,7 +395,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
{!readOnly && !showAddForm && (
|
||||
<div className={styles.sectionHeader}>
|
||||
<span className={styles.sectionTitle}>
|
||||
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
|
||||
{rules.length} {rules.length === 1 ? t('Regel') : t('Regeln')}
|
||||
</span>
|
||||
<div className={styles.headerActions}>
|
||||
{/* View Toggle */}
|
||||
|
|
@ -389,14 +404,14 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
<button
|
||||
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
|
||||
onClick={() => setUseTableView(true)}
|
||||
title="Tabellenansicht"
|
||||
title={t('Tabellenansicht')}
|
||||
>
|
||||
<FaThList />
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
|
||||
onClick={() => setUseTableView(false)}
|
||||
title="Kartenansicht"
|
||||
title={t('Kartenansicht')}
|
||||
>
|
||||
<FaTh />
|
||||
</button>
|
||||
|
|
@ -406,7 +421,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
className={styles.addButton}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<FaPlus /> Neue Regel
|
||||
<FaPlus /> {t('Neue Regel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -427,7 +442,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
|||
<p className={styles.emptyText}>{getEmptyText()}</p>
|
||||
{!readOnly && (
|
||||
<p className={styles.emptyHint}>
|
||||
Klicken Sie auf "Neue Regel" um eine Berechtigung hinzuzufügen.
|
||||
{t('Klicken Sie auf „Neue Regel“, um eine Berechtigung hinzuzufügen.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -465,6 +480,7 @@ interface JsonEditorProps {
|
|||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
|
||||
const { t } = useLanguage();
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -477,7 +493,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
|
|||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON muss ein Array sein');
|
||||
throw new Error(t('JSON muss ein Array sein'));
|
||||
}
|
||||
setError(null);
|
||||
onApply(parsed);
|
||||
|
|
@ -497,8 +513,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
|
|||
/>
|
||||
{error && <div className={styles.jsonError}>{error}</div>}
|
||||
<p className={styles.jsonHint}>
|
||||
Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON.
|
||||
Änderungen werden erst nach Klick auf "Anwenden" übernommen.
|
||||
{t(
|
||||
'Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON. Änderungen werden erst nach Klick auf „Anwenden“ übernommen.'
|
||||
)}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<div className={styles.formActions}>
|
||||
|
|
@ -508,7 +525,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
|
|||
onClick={handleApply}
|
||||
disabled={!!error}
|
||||
>
|
||||
JSON anwenden
|
||||
{t('JSON anwenden')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -531,6 +548,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
featureCode,
|
||||
}) => {
|
||||
const { showError } = useToast();
|
||||
const { t } = useLanguage();
|
||||
const {
|
||||
rules,
|
||||
loading,
|
||||
|
|
@ -602,7 +620,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
setHasChanges(false);
|
||||
onSave?.();
|
||||
} else {
|
||||
showError('Fehler', result.error || 'Fehler beim Speichern');
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Speichern'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -628,9 +646,9 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
|
||||
// Render tabs
|
||||
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
|
||||
{ id: 'DATA', label: 'Daten', icon: <FaTable />, count: groupedRules.DATA.length },
|
||||
{ id: 'DATA', label: t('data'), icon: <FaTable />, count: groupedRules.DATA.length },
|
||||
{ id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length },
|
||||
{ id: 'RESOURCE', label: 'Ressourcen', icon: <FaServer />, count: groupedRules.RESOURCE.length },
|
||||
{ id: 'RESOURCE', label: t('resources'), icon: <FaServer />, count: groupedRules.RESOURCE.length },
|
||||
{ id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length },
|
||||
];
|
||||
|
||||
|
|
@ -639,7 +657,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
<div className={styles.accessRulesEditor}>
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Berechtigungen...</span>
|
||||
<span>{t('loading permissions')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -650,7 +668,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
<div className={styles.editorHeader}>
|
||||
<h3 className={styles.editorTitle}>
|
||||
Berechtigungen{roleName ? `: ${roleName}` : ''}
|
||||
{isTemplate && <span className={styles.templateBadge}>Template</span>}
|
||||
{isTemplate && <span className={styles.templateBadge}>{t('Vorlage')}</span>}
|
||||
</h3>
|
||||
{!readOnly && hasChanges && (
|
||||
<div className={styles.headerActions}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { FaTable, FaDesktop, FaServer, FaTrash } from 'react-icons/fa';
|
|||
import { type AccessRule, type RuleContext, type AccessLevel } from '../../hooks/useAccessRules';
|
||||
import styles from './AccessRules.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
|
@ -74,6 +76,9 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
|||
onUpdate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const opTitle = (op: 'create' | 'read' | 'update' | 'delete') =>
|
||||
({ create: t('Erstellen'), read: t('Lesen'), update: t('Bearbeiten'), delete: t('Löschen') })[op];
|
||||
const handleLevelToggle = (
|
||||
field: 'read' | 'create' | 'update' | 'delete',
|
||||
targetLevel: 'm' | 'g' | 'a',
|
||||
|
|
@ -109,7 +114,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
|||
checked={rule.view}
|
||||
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
|
||||
disabled={readOnly}
|
||||
title="Sichtbar"
|
||||
title={t('Sichtbar')}
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
|
@ -124,7 +129,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
|||
checked={hasLevel(rule[op] as AccessLevel, 'm')}
|
||||
onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)}
|
||||
disabled={readOnly}
|
||||
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Eigene`}
|
||||
title={`${opTitle(op)} - ${t('Eigene')}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
|
@ -137,7 +142,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
|||
checked={hasLevel(rule[op] as AccessLevel, 'g')}
|
||||
onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)}
|
||||
disabled={readOnly}
|
||||
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Gruppe`}
|
||||
title={`${opTitle(op)} - ${t('Gruppe')}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
|
@ -150,7 +155,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
|||
checked={hasLevel(rule[op] as AccessLevel, 'a')}
|
||||
onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)}
|
||||
disabled={readOnly}
|
||||
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Alle`}
|
||||
title={`${opTitle(op)} - ${t('Alle')}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
|
@ -163,7 +168,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
|||
<button
|
||||
className={`${styles.iconButton} ${styles.danger}`}
|
||||
onClick={() => onDelete(rule.id)}
|
||||
title="Regel löschen"
|
||||
title={t('Regel löschen')}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
|
|
@ -184,6 +189,7 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
|||
onUpdate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const isDataContext = context === 'DATA';
|
||||
|
||||
if (rules.length === 0) {
|
||||
|
|
@ -195,13 +201,13 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
|||
<table className={styles.accessRulesTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.colObject}>Objekt (Dot-Notation)</th>
|
||||
<th className={styles.colView}>View</th>
|
||||
<th className={styles.colObject}>{t('object dot notation')}</th>
|
||||
<th className={styles.colView}>{t('Ansicht')}</th>
|
||||
{isDataContext && (
|
||||
<>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>Eigene (m)</th>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>Gruppe (g)</th>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>Alle (a)</th>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>{t('own')}</th>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>{t('group')}</th>
|
||||
<th className={styles.colGroupHeader} colSpan={4}>{t('Alle')}</th>
|
||||
</>
|
||||
)}
|
||||
<th className={styles.colActions}></th>
|
||||
|
|
@ -210,18 +216,18 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
|||
<tr className={styles.subHeader}>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th title="Create">C</th>
|
||||
<th title="Read">R</th>
|
||||
<th title="Update">U</th>
|
||||
<th title="Delete">D</th>
|
||||
<th title="Create">C</th>
|
||||
<th title="Read">R</th>
|
||||
<th title="Update">U</th>
|
||||
<th title="Delete">D</th>
|
||||
<th title="Create">C</th>
|
||||
<th title="Read">R</th>
|
||||
<th title="Update">U</th>
|
||||
<th title="Delete">D</th>
|
||||
<th title={t('Erstellen')}>C</th>
|
||||
<th title={t('Lesen')}>R</th>
|
||||
<th title={t('Bearbeiten')}>U</th>
|
||||
<th title={t('Löschen')}>D</th>
|
||||
<th title={t('Erstellen')}>C</th>
|
||||
<th title={t('Lesen')}>R</th>
|
||||
<th title={t('Bearbeiten')}>U</th>
|
||||
<th title={t('Löschen')}>D</th>
|
||||
<th title={t('Erstellen')}>C</th>
|
||||
<th title={t('Lesen')}>R</th>
|
||||
<th title={t('Bearbeiten')}>U</th>
|
||||
<th title={t('Löschen')}>D</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,289 +0,0 @@
|
|||
/* ActionsPanel Styles */
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--text-secondary, #666);
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #333);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
.actionsList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error-color, #dc3545);
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retryButton:hover {
|
||||
background: var(--primary-hover, #0056b3);
|
||||
}
|
||||
|
||||
/* Method Groups */
|
||||
.methodGroup {
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.methodHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.methodHeader:hover {
|
||||
background: var(--bg-hover, #f0f0f0);
|
||||
}
|
||||
|
||||
.methodHeader svg {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.methodName {
|
||||
flex: 1;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.methodCount {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Method Actions */
|
||||
.methodActions {
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.actionItem {
|
||||
border-bottom: 1px solid var(--border-light, #f0f0f0);
|
||||
}
|
||||
|
||||
.actionItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.actionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.actionHeader:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
}
|
||||
|
||||
.actionInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actionName {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.actionDesc {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #666);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background: var(--primary-color, #007bff);
|
||||
border-color: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Action Details */
|
||||
.actionDetails {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-top: 1px solid var(--border-light, #f0f0f0);
|
||||
}
|
||||
|
||||
.actionDetails h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Parameters */
|
||||
.parameters {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.parameters ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.param {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.paramName {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--error-color, #dc3545);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.paramType {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-code, #e9ecef);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.paramDesc {
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* Example JSON */
|
||||
.exampleJson {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.exampleJson pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-code, #1e1e1e);
|
||||
color: var(--text-code, #d4d4d4);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.insertButton {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.insertButton:hover {
|
||||
background: var(--primary-hover, #0056b3);
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
/**
|
||||
* ActionsPanel
|
||||
*
|
||||
* Displays available workflow actions for copy/paste into templates.
|
||||
* Groups actions by method and shows parameters + example JSON.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useWorkflowActions, type WorkflowAction } from '../../hooks/useAutomations';
|
||||
import { FaSearch, FaCopy, FaChevronDown, FaChevronRight, FaCheck } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import styles from './ActionsPanel.module.css';
|
||||
|
||||
interface ActionsPanelProps {
|
||||
/** Callback when action JSON is inserted (optional) */
|
||||
onInsert?: (actionJson: string) => void;
|
||||
/** Callback when action JSON is copied (optional) */
|
||||
onCopy?: (actionJson: string) => void;
|
||||
}
|
||||
|
||||
export const ActionsPanel: React.FC<ActionsPanelProps> = ({ onInsert, onCopy }) => {
|
||||
const { actions, loading, error, fetchActions } = useWorkflowActions();
|
||||
const { showSuccess } = useToast();
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expandedMethods, setExpandedMethods] = useState<Set<string>>(new Set());
|
||||
const [expandedAction, setExpandedAction] = useState<string | null>(null);
|
||||
const [copiedAction, setCopiedAction] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchActions();
|
||||
}, [fetchActions]);
|
||||
|
||||
// Filter actions by search term
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!filter) return actions;
|
||||
const lower = filter.toLowerCase();
|
||||
return actions.filter(a =>
|
||||
a.method.toLowerCase().includes(lower) ||
|
||||
a.action.toLowerCase().includes(lower) ||
|
||||
a.description.toLowerCase().includes(lower) ||
|
||||
a.actionId.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [actions, filter]);
|
||||
|
||||
// Group actions by method
|
||||
const groupedActions = useMemo(() => {
|
||||
const groups: Record<string, WorkflowAction[]> = {};
|
||||
filteredActions.forEach(action => {
|
||||
if (!groups[action.method]) {
|
||||
groups[action.method] = [];
|
||||
}
|
||||
groups[action.method].push(action);
|
||||
});
|
||||
return groups;
|
||||
}, [filteredActions]);
|
||||
|
||||
// Toggle method expansion
|
||||
const toggleMethod = (method: string) => {
|
||||
setExpandedMethods(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(method)) {
|
||||
newSet.delete(method);
|
||||
} else {
|
||||
newSet.add(method);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle action details
|
||||
const toggleAction = (actionId: string) => {
|
||||
setExpandedAction(prev => prev === actionId ? null : actionId);
|
||||
};
|
||||
|
||||
// Copy action JSON to clipboard
|
||||
const handleCopy = async (action: WorkflowAction) => {
|
||||
const json = JSON.stringify(action.exampleJson, null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(json);
|
||||
setCopiedAction(action.actionId);
|
||||
setTimeout(() => setCopiedAction(null), 2000);
|
||||
showSuccess('JSON kopiert');
|
||||
onCopy?.(json);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Insert action JSON
|
||||
const handleInsert = (action: WorkflowAction) => {
|
||||
const json = JSON.stringify(action.exampleJson, null, 2);
|
||||
onInsert?.(json);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.loading}>Lade Actions...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.error}>Fehler: {error}</div>
|
||||
<button className={styles.retryButton} onClick={() => fetchActions()}>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>Verfügbare Actions</h3>
|
||||
<div className={styles.searchBox}>
|
||||
<FaSearch className={styles.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actionsList}>
|
||||
{Object.keys(groupedActions).length === 0 ? (
|
||||
<div className={styles.empty}>Keine Actions gefunden</div>
|
||||
) : (
|
||||
Object.entries(groupedActions).map(([method, methodActions]) => (
|
||||
<div key={method} className={styles.methodGroup}>
|
||||
<button
|
||||
className={styles.methodHeader}
|
||||
onClick={() => toggleMethod(method)}
|
||||
>
|
||||
{expandedMethods.has(method) ? <FaChevronDown /> : <FaChevronRight />}
|
||||
<span className={styles.methodName}>{method}</span>
|
||||
<span className={styles.methodCount}>{methodActions.length}</span>
|
||||
</button>
|
||||
|
||||
{expandedMethods.has(method) && (
|
||||
<div className={styles.methodActions}>
|
||||
{methodActions.map(action => (
|
||||
<div key={action.actionId} className={styles.actionItem}>
|
||||
<div
|
||||
className={styles.actionHeader}
|
||||
onClick={() => toggleAction(action.actionId)}
|
||||
>
|
||||
<div className={styles.actionInfo}>
|
||||
<span className={styles.actionName}>{action.action}</span>
|
||||
<span className={styles.actionDesc}>{action.description}</span>
|
||||
</div>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={(e) => { e.stopPropagation(); handleCopy(action); }}
|
||||
title="JSON kopieren"
|
||||
>
|
||||
{copiedAction === action.actionId ? <FaCheck /> : <FaCopy />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedAction === action.actionId && (
|
||||
<div className={styles.actionDetails}>
|
||||
{action.parameters.length > 0 && (
|
||||
<div className={styles.parameters}>
|
||||
<h5>Parameter:</h5>
|
||||
<ul>
|
||||
{action.parameters.map(param => (
|
||||
<li key={param.name} className={styles.param}>
|
||||
<span className={styles.paramName}>
|
||||
{param.name}
|
||||
{param.required && <span className={styles.required}>*</span>}
|
||||
</span>
|
||||
<span className={styles.paramType}>{param.type}</span>
|
||||
{param.description && (
|
||||
<span className={styles.paramDesc}>{param.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.exampleJson}>
|
||||
<h5>Beispiel JSON:</h5>
|
||||
<pre>{JSON.stringify(action.exampleJson, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
{onInsert && (
|
||||
<button
|
||||
className={styles.insertButton}
|
||||
onClick={() => handleInsert(action)}
|
||||
>
|
||||
In Template einfügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionsPanel;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { ActionsPanel } from './ActionsPanel';
|
||||
export { default } from './ActionsPanel';
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
/**
|
||||
* Automation2FlowEditor
|
||||
*
|
||||
* n8n-style flow builder with backend-driven node list.
|
||||
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import {
|
||||
fetchNodeTypes,
|
||||
executeGraph,
|
||||
fetchWorkflows,
|
||||
fetchWorkflow,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
type NodeType,
|
||||
type NodeTypeCategory,
|
||||
type Automation2Graph,
|
||||
type Automation2Workflow,
|
||||
type ExecuteGraphResponse,
|
||||
type WorkflowEntryPoint,
|
||||
} from '../../../api/automation2Api';
|
||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||
import { NodeSidebar } from './NodeSidebar';
|
||||
import { CanvasHeader } from './CanvasHeader';
|
||||
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
||||
import {
|
||||
syncCanvasStartNode,
|
||||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] =>
|
||||
buildInvocationsForPrimaryKind('manual', [], 'Jetzt ausführen');
|
||||
|
||||
interface Automation2FlowEditorProps {
|
||||
instanceId: string;
|
||||
language?: string;
|
||||
/** When set, load this workflow on mount (e.g. from workflows list edit) */
|
||||
initialWorkflowId?: string | null;
|
||||
}
|
||||
|
||||
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||
instanceId,
|
||||
language = 'de',
|
||||
initialWorkflowId,
|
||||
}) => {
|
||||
const { request } = useApiRequest();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup'])
|
||||
);
|
||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
|
||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||
|
||||
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
|
||||
|
||||
const nodeOutputsPreview = useMemo(
|
||||
() =>
|
||||
buildNodeOutputsPreview(canvasNodes, executeResult?.nodeOutputs as Record<string, unknown> | undefined),
|
||||
[canvasNodes, executeResult?.nodeOutputs]
|
||||
);
|
||||
|
||||
const applyGraphWithSync = useCallback(
|
||||
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
||||
const inv = wfInvocations?.length ? wfInvocations : DEFAULT_INVOCATIONS();
|
||||
setInvocations(inv);
|
||||
if (!graph?.nodes?.length) {
|
||||
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
|
||||
setCanvasNodes(synced.nodes);
|
||||
setCanvasConnections(synced.connections);
|
||||
return;
|
||||
}
|
||||
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
||||
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
|
||||
setCanvasNodes(synced.nodes);
|
||||
setCanvasConnections(synced.connections);
|
||||
},
|
||||
[nodeTypes, language]
|
||||
);
|
||||
|
||||
const handleFromApiGraph = useCallback(
|
||||
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
|
||||
applyGraphWithSync(graph, wfInvocations);
|
||||
},
|
||||
[applyGraphWithSync]
|
||||
);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||
if (graph.nodes.length === 0) {
|
||||
setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' });
|
||||
return;
|
||||
}
|
||||
setExecuting(true);
|
||||
setExecuteResult(null);
|
||||
try {
|
||||
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
|
||||
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
|
||||
...(ep ? { entryPointId: ep } : {}),
|
||||
});
|
||||
setExecuteResult(result);
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||
if (graph.nodes.length === 0) {
|
||||
setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (currentWorkflowId) {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
} else {
|
||||
const label = await promptInput('Workflow-Name:', {
|
||||
title: 'Workflow speichern',
|
||||
defaultValue: 'Neuer Workflow',
|
||||
placeholder: 'Name des Workflows',
|
||||
});
|
||||
if (!label) {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const created = await createWorkflow(request, instanceId, {
|
||||
label: label.trim() || 'Neuer Workflow',
|
||||
graph,
|
||||
invocations,
|
||||
});
|
||||
setCurrentWorkflowId(created.id);
|
||||
if (created.invocations?.length) setInvocations(created.invocations);
|
||||
setWorkflows((prev) => [...prev, created]);
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (workflowId: string) => {
|
||||
try {
|
||||
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
||||
if (wf.graph) {
|
||||
handleFromApiGraph(wf.graph, wf.invocations);
|
||||
} else {
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
|
||||
);
|
||||
|
||||
const handleWorkflowSelect = useCallback(
|
||||
(workflowId: string | null) => {
|
||||
setCurrentWorkflowId(workflowId);
|
||||
if (workflowId) handleLoad(workflowId);
|
||||
else {
|
||||
setExecuteResult(null);
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
|
||||
}
|
||||
},
|
||||
[handleLoad, applyGraphWithSync]
|
||||
);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
setCurrentWorkflowId(null);
|
||||
setExecuteResult(null);
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
|
||||
}, [applyGraphWithSync]);
|
||||
|
||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
const next = { ...n, parameters };
|
||||
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
||||
const cases = (parameters.cases as unknown[]) ?? [];
|
||||
next.outputs = Math.max(1, cases.length);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
const merged = { ...(n.parameters ?? {}), ...patch };
|
||||
const next = { ...n, parameters: merged };
|
||||
if (n.type === 'flow.switch' && 'cases' in merged) {
|
||||
const cases = (merged.cases as unknown[]) ?? [];
|
||||
next.outputs = Math.max(1, cases.length);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleNodeUpdate = useCallback(
|
||||
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleApplyWorkflowConfiguration = useCallback(
|
||||
(next: WorkflowEntryPoint[]) => {
|
||||
setInvocations(next);
|
||||
setCanvasNodes((nodes) => {
|
||||
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
|
||||
setCanvasConnections(r.connections);
|
||||
return r.nodes;
|
||||
});
|
||||
},
|
||||
[canvasConnections, nodeTypes, language]
|
||||
);
|
||||
|
||||
const loadNodeTypes = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchNodeTypes(request, instanceId, language);
|
||||
setNodeTypes(data.nodeTypes);
|
||||
setCategories(data.categories);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setNodeTypes([]);
|
||||
setCategories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, language, request]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const items = await fetchWorkflows(request, instanceId);
|
||||
setWorkflows(items);
|
||||
} catch (e) {
|
||||
console.error(`${LOG} loadWorkflows failed`, e);
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodeTypes();
|
||||
}, [loadNodeTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkflows();
|
||||
}, [loadWorkflows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId && nodeTypes.length > 0) {
|
||||
handleWorkflowSelect(initialWorkflowId);
|
||||
}
|
||||
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect, nodeTypes.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || nodeTypes.length === 0) return;
|
||||
if (currentWorkflowId || initialWorkflowId) return;
|
||||
if (canvasNodes.length > 0) return;
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
|
||||
}, [
|
||||
loading,
|
||||
nodeTypes.length,
|
||||
currentWorkflowId,
|
||||
initialWorkflowId,
|
||||
canvasNodes.length,
|
||||
applyGraphWithSync,
|
||||
]);
|
||||
|
||||
const toggleCategory = useCallback((id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDropNodeType = useCallback(
|
||||
(nodeTypeId: string, x: number, y: number) => {
|
||||
if (nodeTypeId.startsWith('trigger.')) return;
|
||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||
if (!nt) return;
|
||||
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const label =
|
||||
typeof nt.label === 'string' ? nt.label : (nt.label as Record<string, string>)?.[language] ?? nt.id;
|
||||
setCanvasNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: nodeTypeId,
|
||||
x,
|
||||
y,
|
||||
label,
|
||||
title: label,
|
||||
color: nt.meta?.color,
|
||||
inputs: nt.inputs ?? 1,
|
||||
outputs: nt.outputs ?? 1,
|
||||
parameters: {},
|
||||
},
|
||||
]);
|
||||
},
|
||||
[nodeTypes, language]
|
||||
);
|
||||
|
||||
const renderSidebar = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||
</div>
|
||||
<div className={styles.loading}>
|
||||
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
|
||||
<p>Lade Node-Typen...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||
</div>
|
||||
<div className={styles.error}>
|
||||
<p>{error}</p>
|
||||
<button className={styles.retryButton} onClick={loadNodeTypes}>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NodeSidebar
|
||||
nodeTypes={nodeTypes}
|
||||
categories={categories}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
language={language}
|
||||
expandedCategories={expandedCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
excludedCategories={sidebarExcludedCategories}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const configurableSelected =
|
||||
selectedNode &&
|
||||
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) =>
|
||||
selectedNode.type.startsWith(p)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{renderSidebar()}
|
||||
|
||||
<div className={styles.canvas}>
|
||||
<CanvasHeader
|
||||
workflows={workflows}
|
||||
currentWorkflowId={currentWorkflowId}
|
||||
onWorkflowSelect={handleWorkflowSelect}
|
||||
onNew={handleNew}
|
||||
onSave={handleSave}
|
||||
onExecute={handleExecute}
|
||||
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
||||
saving={saving}
|
||||
executing={executing}
|
||||
hasNodes={canvasNodes.length > 0}
|
||||
executeResult={executeResult}
|
||||
/>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<FlowCanvas
|
||||
nodes={canvasNodes}
|
||||
connections={canvasConnections}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={setCanvasNodes}
|
||||
onConnectionsChange={setCanvasConnections}
|
||||
onDropNodeType={handleDropNodeType}
|
||||
getLabel={(node) => node.title ?? node.label ?? node.type}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
onSelectionChange={setSelectedNode}
|
||||
/>
|
||||
</div>
|
||||
{configurableSelected && selectedNode && (
|
||||
<Automation2DataFlowProvider
|
||||
node={selectedNode}
|
||||
nodes={canvasNodes}
|
||||
connections={canvasConnections}
|
||||
nodeOutputsPreview={nodeOutputsPreview}
|
||||
nodeTypes={nodeTypes}
|
||||
language={language}
|
||||
>
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
||||
language={language}
|
||||
onParametersChange={handleNodeParametersChange}
|
||||
onMergeNodeParameters={handleMergeNodeParameters}
|
||||
onNodeUpdate={handleNodeUpdate}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
/>
|
||||
</Automation2DataFlowProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PromptDialog />
|
||||
<WorkflowConfigurationModal
|
||||
open={workflowSettingsOpen}
|
||||
onClose={() => setWorkflowSettingsOpen(false)}
|
||||
invocations={invocations}
|
||||
onApply={handleApplyWorkflowConfiguration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Automation2FlowEditor;
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen) and execute result.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaCog, FaPlay, FaSpinner } from 'react-icons/fa';
|
||||
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/automation2Api';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
workflows: Automation2Workflow[];
|
||||
currentWorkflowId: string | null;
|
||||
onWorkflowSelect: (workflowId: string | null) => void;
|
||||
onNew: () => void;
|
||||
onSave: () => void;
|
||||
onExecute: () => void;
|
||||
onWorkflowSettings?: () => void;
|
||||
saving: boolean;
|
||||
executing: boolean;
|
||||
hasNodes: boolean;
|
||||
executeResult: ExecuteGraphResponse | null;
|
||||
}
|
||||
|
||||
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||
workflows,
|
||||
currentWorkflowId,
|
||||
onWorkflowSelect,
|
||||
onNew,
|
||||
onSave,
|
||||
onExecute,
|
||||
onWorkflowSettings,
|
||||
saving,
|
||||
executing,
|
||||
hasNodes,
|
||||
executeResult,
|
||||
}) => (
|
||||
<div className={styles.canvasHeader}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
|
||||
Workflow-Editor
|
||||
</h4>
|
||||
{onWorkflowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasGearBtn}
|
||||
title="Workflow-Konfiguration (Einstieg / Starts)"
|
||||
aria-label="Workflow-Konfiguration"
|
||||
onClick={onWorkflowSettings}
|
||||
>
|
||||
<FaCog />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={styles.retryButton} onClick={onNew}>
|
||||
Neu
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onSave}
|
||||
disabled={saving || !hasNodes}
|
||||
>
|
||||
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
|
||||
</button>
|
||||
<select
|
||||
value={currentWorkflowId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? e.target.value : null;
|
||||
onWorkflowSelect(id);
|
||||
}}
|
||||
style={{ padding: '0.4rem', minWidth: 180 }}
|
||||
>
|
||||
<option value="">— Workflow laden —</option>
|
||||
{workflows.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onExecute}
|
||||
disabled={executing || !hasNodes}
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
||||
Ausführen…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaPlay style={{ marginRight: '0.5rem' }} />
|
||||
Ausführen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{executeResult && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.875rem',
|
||||
background: executeResult.success
|
||||
? 'rgba(40,167,69,0.15)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'rgba(0,123,255,0.15)'
|
||||
: 'rgba(220,53,69,0.15)',
|
||||
color: executeResult.success
|
||||
? 'var(--success-color,#28a745)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'var(--primary-color,#007bff)'
|
||||
: 'var(--danger-color,#dc3545)',
|
||||
}}
|
||||
>
|
||||
{executeResult.success ? (
|
||||
<>✓ Ausführung abgeschlossen.</>
|
||||
) : (executeResult as { paused?: boolean }).paused ? (
|
||||
<>
|
||||
⏸ Workflow pausiert. Öffne <strong>Workflows & Tasks</strong> in der Sidebar, um den
|
||||
Task zu bearbeiten.
|
||||
</>
|
||||
) : (
|
||||
<>✗ {executeResult.error ?? 'Unbekannter Fehler'}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* NodeListItem - Draggable node type item for the sidebar.
|
||||
* Used in both regular categories and I/O sub-groups.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeType } from '../../../api/automation2Api';
|
||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
interface NodeListItemProps {
|
||||
node: NodeType;
|
||||
language: string;
|
||||
getLabel: GetLabelFn;
|
||||
getCategoryIcon?: (categoryId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const NodeListItem: React.FC<NodeListItemProps> = ({
|
||||
node,
|
||||
language,
|
||||
getLabel,
|
||||
getCategoryIcon: getIcon = getCategoryIcon,
|
||||
}) => (
|
||||
<div
|
||||
className={styles.nodeItem}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: node.id }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.nodeItemIcon}
|
||||
style={{
|
||||
backgroundColor: node.meta?.color
|
||||
? `${node.meta.color}20`
|
||||
: 'var(--bg-tertiary, #e9ecef)',
|
||||
color: node.meta?.color ?? 'var(--text-secondary, #666)',
|
||||
}}
|
||||
>
|
||||
{getIcon(node.category)}
|
||||
</div>
|
||||
<div className={styles.nodeItemInfo}>
|
||||
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
|
||||
<span className={styles.nodeItemDesc}>{getLabel(node.description, language)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/**
|
||||
* AI node config - prompt, query, document options per node type.
|
||||
* Prompt/query fields support static value or node reference (Data Picker).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
import { DynamicValueField } from '../shared/DynamicValueField';
|
||||
|
||||
const AI_FIELD_CONFIG: Record<string, { label: string; key: string; type: 'textarea' | 'input' | 'select' | 'dynamic'; options?: string[] }[]> = {
|
||||
'ai.prompt': [{ label: 'Prompt', key: 'prompt', type: 'textarea' }],
|
||||
'ai.webResearch': [{ label: 'Query', key: 'query', type: 'dynamic' }],
|
||||
'ai.summarizeDocument': [
|
||||
{ label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] },
|
||||
],
|
||||
'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }],
|
||||
'ai.convertDocument': [
|
||||
{ label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] },
|
||||
],
|
||||
'ai.generateDocument': [{ label: 'Prompt', key: 'prompt', type: 'dynamic' }],
|
||||
'ai.generateCode': [
|
||||
{ label: 'Prompt', key: 'prompt', type: 'dynamic' },
|
||||
{ label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] },
|
||||
],
|
||||
};
|
||||
|
||||
export const AiNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam, nodeType = 'ai.prompt' }) => {
|
||||
const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt'];
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((f) => {
|
||||
if (f.type === 'dynamic') {
|
||||
return (
|
||||
<DynamicValueField
|
||||
key={f.key}
|
||||
paramKey={f.key}
|
||||
value={params[f.key]}
|
||||
onChange={updateParam}
|
||||
label={f.label}
|
||||
fieldType="textarea"
|
||||
rows={4}
|
||||
placeholder={f.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (f.type === 'textarea') {
|
||||
return (
|
||||
<div key={f.key}>
|
||||
<label>{f.label}</label>
|
||||
<textarea
|
||||
value={(params[f.key] as string) ?? ''}
|
||||
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||
placeholder={f.label}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (f.type === 'select') {
|
||||
return (
|
||||
<div key={f.key}>
|
||||
<label>{f.label}</label>
|
||||
<select
|
||||
value={(params[f.key] as string) ?? (f.options?.[0] ?? '')}
|
||||
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||
>
|
||||
{(f.options ?? []).map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={f.key}>
|
||||
<label>{f.label}</label>
|
||||
<input
|
||||
value={(params[f.key] as string) ?? ''}
|
||||
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||
placeholder={f.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Approval node config
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
|
||||
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||
<>
|
||||
<div>
|
||||
<label>Titel</label>
|
||||
<input
|
||||
value={(params.title as string) ?? ''}
|
||||
onChange={(e) => updateParam('title', e.target.value)}
|
||||
placeholder="Genehmigungstitel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Beschreibung</label>
|
||||
<textarea
|
||||
value={(params.description as string) ?? ''}
|
||||
onChange={(e) => updateParam('description', e.target.value)}
|
||||
placeholder="Was genehmigt werden soll"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* Comment node config
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
|
||||
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||
<>
|
||||
<div>
|
||||
<label>Platzhalter</label>
|
||||
<input
|
||||
value={(params.placeholder as string) ?? ''}
|
||||
onChange={(e) => updateParam('placeholder', e.target.value)}
|
||||
placeholder="Kommentar eingeben..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(params.required as boolean) ?? true}
|
||||
onChange={(e) => updateParam('required', e.target.checked)}
|
||||
/>
|
||||
Pflichtfeld
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* Confirmation node config
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
|
||||
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||
<>
|
||||
<div>
|
||||
<label>Frage</label>
|
||||
<input
|
||||
value={(params.question as string) ?? ''}
|
||||
onChange={(e) => updateParam('question', e.target.value)}
|
||||
placeholder="Möchten Sie bestätigen?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Bestätigen-Button</label>
|
||||
<input
|
||||
value={(params.confirmLabel as string) ?? 'Confirm'}
|
||||
onChange={(e) => updateParam('confirmLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Ablehnen-Button</label>
|
||||
<input
|
||||
value={(params.rejectLabel as string) ?? 'Reject'}
|
||||
onChange={(e) => updateParam('rejectLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/**
|
||||
* Email node config - connection selector, folder dropdown, query, subject, body.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
|
||||
|
||||
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||
params,
|
||||
updateParam,
|
||||
instanceId,
|
||||
request,
|
||||
nodeType = 'email.checkEmail',
|
||||
}) => {
|
||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||
const [folders, setFolders] = useState<BrowseEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [foldersLoading, setFoldersLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceId && request) {
|
||||
setLoading(true);
|
||||
fetchConnections(request, instanceId)
|
||||
.then(setConnections)
|
||||
.catch(() => setConnections([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
const connectionId = (params.connectionId as string) ?? '';
|
||||
const selectedConn = connections.find((c) => c.id === connectionId);
|
||||
const mailService = selectedConn?.authority === 'google' ? 'gmail' : 'outlook';
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceId && request && connectionId) {
|
||||
setFoldersLoading(true);
|
||||
fetchBrowse(request, instanceId, connectionId, mailService, '/')
|
||||
.then((r) => setFolders(r.items.filter((e) => e.isFolder)))
|
||||
.catch(() => setFolders([]))
|
||||
.finally(() => setFoldersLoading(false));
|
||||
} else {
|
||||
setFolders([]);
|
||||
}
|
||||
}, [instanceId, request, connectionId, mailService]);
|
||||
|
||||
const isDraft = nodeType === 'email.draftEmail';
|
||||
const isSearch = nodeType === 'email.searchEmail';
|
||||
const folderValue = (params.folder as string) ?? (isSearch ? 'All' : 'Inbox');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Account</label>
|
||||
<select
|
||||
value={connectionId}
|
||||
onChange={(e) => updateParam('connectionId', e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.externalEmail ?? c.externalUsername ?? c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!isDraft && (
|
||||
<div>
|
||||
<label>Folder</label>
|
||||
<select
|
||||
value={folderValue}
|
||||
onChange={(e) => updateParam('folder', e.target.value)}
|
||||
disabled={foldersLoading || !connectionId}
|
||||
>
|
||||
<option value="">
|
||||
{foldersLoading ? 'Loading folders...' : !connectionId ? 'Select account first' : 'Select folder'}
|
||||
</option>
|
||||
{isSearch && <option value="All">All</option>}
|
||||
{folders.length > 0
|
||||
? folders.map((f) => {
|
||||
const folderId = (f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id || '';
|
||||
const value = folderId || f.name;
|
||||
if (!value) return null;
|
||||
return (
|
||||
<option key={value} value={value}>
|
||||
{f.name}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
: !isSearch && (
|
||||
<>
|
||||
<option value="Inbox">Inbox</option>
|
||||
<option value="Drafts">Drafts</option>
|
||||
<option value="SentItems">Sent Items</option>
|
||||
<option value="DeletedItems">Deleted Items</option>
|
||||
<option value="JunkEmail">Junk Email</option>
|
||||
</>
|
||||
)}
|
||||
{folderValue &&
|
||||
!folders.some(
|
||||
(f) =>
|
||||
((f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id) === folderValue
|
||||
) &&
|
||||
folderValue !== 'All' && (
|
||||
<option value={folderValue}>{folderValue}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{isSearch && (
|
||||
<>
|
||||
<div>
|
||||
<label>Search query (optional)</label>
|
||||
<input
|
||||
value={(params.query as string) ?? ''}
|
||||
onChange={(e) => updateParam('query', e.target.value)}
|
||||
placeholder="General search term (subject, body, from)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>From address (optional)</label>
|
||||
<input
|
||||
value={(params.fromAddress as string) ?? ''}
|
||||
onChange={(e) => updateParam('fromAddress', e.target.value)}
|
||||
placeholder="e.g. sender@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>To address (optional)</label>
|
||||
<input
|
||||
value={(params.toAddress as string) ?? ''}
|
||||
onChange={(e) => updateParam('toAddress', e.target.value)}
|
||||
placeholder="e.g. recipient@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Subject contains (optional)</label>
|
||||
<input
|
||||
value={(params.subjectContains as string) ?? ''}
|
||||
onChange={(e) => updateParam('subjectContains', e.target.value)}
|
||||
placeholder="Word or phrase in subject"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Body/content contains (optional)</label>
|
||||
<input
|
||||
value={(params.bodyContains as string) ?? ''}
|
||||
onChange={(e) => updateParam('bodyContains', e.target.value)}
|
||||
placeholder="Word or phrase in email body"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="searchHasAttachment"
|
||||
checked={!!(params.hasAttachment as boolean)}
|
||||
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="searchHasAttachment">Only emails with attachment</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Limit</label>
|
||||
<input
|
||||
type="number"
|
||||
value={(params.limit as number) ?? 100}
|
||||
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{nodeType === 'email.checkEmail' && (
|
||||
<>
|
||||
<div>
|
||||
<label>From address (optional)</label>
|
||||
<input
|
||||
value={(params.fromAddress as string) ?? ''}
|
||||
onChange={(e) => updateParam('fromAddress', e.target.value)}
|
||||
placeholder="e.g. sender@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Subject contains (optional)</label>
|
||||
<input
|
||||
value={(params.subjectContains as string) ?? ''}
|
||||
onChange={(e) => updateParam('subjectContains', e.target.value)}
|
||||
placeholder="Word or phrase in subject"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hasAttachment"
|
||||
checked={!!(params.hasAttachment as boolean)}
|
||||
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="hasAttachment">Only emails with attachment</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Limit</label>
|
||||
<input
|
||||
type="number"
|
||||
value={(params.limit as number) ?? 100}
|
||||
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isDraft && (
|
||||
<>
|
||||
<div>
|
||||
<label>Subject</label>
|
||||
<input
|
||||
value={(params.subject as string) ?? ''}
|
||||
onChange={(e) => updateParam('subject', e.target.value)}
|
||||
placeholder="Email subject (or leave empty if connected to AI node above)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Body</label>
|
||||
<textarea
|
||||
value={(params.body as string) ?? ''}
|
||||
onChange={(e) => updateParam('body', e.target.value)}
|
||||
placeholder="Email body (or leave empty if connected to AI node above)"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>To (optional)</label>
|
||||
<input
|
||||
value={(params.to as string) ?? ''}
|
||||
onChange={(e) => updateParam('to', e.target.value)}
|
||||
placeholder="Recipient(s) (or from AI when connected)"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
/**
|
||||
* File Create node config - multiple content sources, output format, title, template, language.
|
||||
* Contents are concatenated in order (nacheinander geschrieben).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
import { RefSourceSelect } from '../shared/RefSourceSelect';
|
||||
import { isRef, type DataRef } from '../shared/dataRef';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
const OUTPUT_FORMATS = ['docx', 'pdf', 'txt', 'md', 'html', 'xlsx', 'csv', 'json'];
|
||||
const TEMPLATE_OPTIONS = ['default', 'corporate', 'minimal'];
|
||||
const LANGUAGES = ['de', 'en', 'fr', 'it', 'es'];
|
||||
|
||||
function normalizeContentSources(v: unknown): (DataRef | null)[] {
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((x) => (isRef(x) ? x : null));
|
||||
}
|
||||
if (isRef(v)) return [v];
|
||||
return [];
|
||||
}
|
||||
|
||||
export const FileCreateNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const contentSources = normalizeContentSources(params.contentSources ?? params.contentSource ?? []);
|
||||
|
||||
const setContentSources = (next: (DataRef | null)[]) => {
|
||||
updateParam('contentSources', next);
|
||||
if (params.contentSource !== undefined) updateParam('contentSource', undefined);
|
||||
};
|
||||
|
||||
const setItem = (index: number, ref: DataRef | null) => {
|
||||
const next = [...contentSources];
|
||||
next[index] = ref;
|
||||
setContentSources(next);
|
||||
};
|
||||
|
||||
const addItem = () => setContentSources([...contentSources, null]);
|
||||
const removeItem = (index: number) => setContentSources(contentSources.filter((_, i) => i !== index));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.fileCreateContentSources}>
|
||||
<label>Inhalte (welche Kontexte nacheinander in die Datei?)</label>
|
||||
{contentSources.map((ref, i) => (
|
||||
<div key={i} className={styles.contentSourceRow}>
|
||||
<RefSourceSelect
|
||||
value={ref}
|
||||
onChange={(r) => setItem(i, r)}
|
||||
placeholder="Quelle wählen…"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.contentSourceRemoveBtn}
|
||||
onClick={() => removeItem(i)}
|
||||
title="Entfernen"
|
||||
aria-label="Inhalt entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className={styles.contentSourceAddBtn} onClick={addItem}>
|
||||
+ Inhalt hinzufügen
|
||||
</button>
|
||||
{contentSources.length === 0 && (
|
||||
<p className={styles.dynamicValueEmptyHint}>
|
||||
Leer = Kontext vom verbundenen Node. Fügen Sie Inhalte hinzu, um mehrere Quellen zu kombinieren.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label>Ausgabeformat</label>
|
||||
<select
|
||||
value={(params.outputFormat as string) ?? 'docx'}
|
||||
onChange={(e) => updateParam('outputFormat', e.target.value)}
|
||||
>
|
||||
{OUTPUT_FORMATS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Titel</label>
|
||||
<input
|
||||
value={(params.title as string) ?? ''}
|
||||
onChange={(e) => updateParam('title', e.target.value)}
|
||||
placeholder="Dokumenttitel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Vorlage / Stil</label>
|
||||
<select
|
||||
value={(params.templateName as string) ?? 'default'}
|
||||
onChange={(e) => updateParam('templateName', e.target.value)}
|
||||
>
|
||||
{TEMPLATE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Sprache</label>
|
||||
<select
|
||||
value={(params.language as string) ?? 'de'}
|
||||
onChange={(e) => updateParam('language', e.target.value)}
|
||||
>
|
||||
{LANGUAGES.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
/**
|
||||
* Review node config - content reference supports static value or node reference.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
import { DynamicValueField } from '../shared/DynamicValueField';
|
||||
|
||||
export const ReviewNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||
<DynamicValueField
|
||||
paramKey="contentRef"
|
||||
value={params.contentRef}
|
||||
onChange={updateParam}
|
||||
label="Content-Referenz"
|
||||
fieldType="input"
|
||||
placeholder="{{nodeId.field}}"
|
||||
/>
|
||||
);
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
/**
|
||||
* Selection node config
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
|
||||
export const SelectionNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
|
||||
return (
|
||||
<div>
|
||||
<label>Optionen</label>
|
||||
{options.map((o, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<input
|
||||
placeholder="value"
|
||||
value={o.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...options];
|
||||
next[i] = { ...next[i], value: e.target.value };
|
||||
updateParam('options', next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="label"
|
||||
value={o.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...options];
|
||||
next[i] = { ...next[i], label: e.target.value };
|
||||
updateParam('options', next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => updateParam('options', [...options, { value: '', label: '' }])}>
|
||||
+ Option
|
||||
</button>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(params.multiple as boolean) ?? false}
|
||||
onChange={(e) => updateParam('multiple', e.target.checked)}
|
||||
/>
|
||||
Mehrfachauswahl
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
/**
|
||||
* SharePoint node config — connection selector, paths, search.
|
||||
* All nodes use SharepointBrowseTree with the selected connection (fetchBrowse + onLoadChildren).
|
||||
* Folder-style nodes (list, upload target, copy destination): folders only, folder selection.
|
||||
* File-style nodes (read, download, find path, copy source): file selection; folders expand only.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
|
||||
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
|
||||
|
||||
const browseDetailsStyle: React.CSSProperties = {
|
||||
marginTop: 12,
|
||||
border: '1px solid var(--border-color, #e0e0e0)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-secondary, #f8f9fa)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const browseSummaryStyle: React.CSSProperties = {
|
||||
padding: '0.5rem 0.75rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
userSelect: 'none',
|
||||
};
|
||||
|
||||
const browseBodyStyle: React.CSSProperties = {
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
function browsePanelTitle(nodeType: string): string {
|
||||
switch (nodeType) {
|
||||
case 'sharepoint.uploadFile':
|
||||
return 'Zielordner durchsuchen';
|
||||
case 'sharepoint.listFiles':
|
||||
return 'Ordner durchsuchen';
|
||||
case 'sharepoint.readFile':
|
||||
return 'Datei auswählen';
|
||||
case 'sharepoint.downloadFile':
|
||||
return 'Datei auswählen';
|
||||
case 'sharepoint.findFile':
|
||||
return 'Pfad aus Bibliothek wählen';
|
||||
default:
|
||||
return 'SharePoint durchsuchen';
|
||||
}
|
||||
}
|
||||
|
||||
/** Folder / location pickers — tree shows folders only; selecting sets folder path. */
|
||||
function isFolderPickerNode(nodeType: string): boolean {
|
||||
return nodeType === 'sharepoint.uploadFile' || nodeType === 'sharepoint.listFiles';
|
||||
}
|
||||
|
||||
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||
params,
|
||||
updateParam,
|
||||
instanceId,
|
||||
request,
|
||||
nodeType = 'sharepoint.findFile',
|
||||
}) => {
|
||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||
const [browseExpanded, setBrowseExpanded] = useState(false);
|
||||
const [findFileBrowseExpanded, setFindFileBrowseExpanded] = useState(false);
|
||||
const [copySourceExpanded, setCopySourceExpanded] = useState(false);
|
||||
const [copyDestExpanded, setCopyDestExpanded] = useState(false);
|
||||
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||
|
||||
const connectionId = (params.connectionId as string) ?? '';
|
||||
const path =
|
||||
(params.path as string) ?? (params.filePath as string) ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceId && request) {
|
||||
setConnectionsLoading(true);
|
||||
fetchConnections(request, instanceId)
|
||||
.then(setConnections)
|
||||
.catch(() => setConnections([]))
|
||||
.finally(() => setConnectionsLoading(false));
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
const loadChildren = useCallback(
|
||||
async (pathToLoad: string): Promise<BrowseEntry[]> => {
|
||||
if (!instanceId || !request || !connectionId) return [];
|
||||
const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
|
||||
return r?.items ?? [];
|
||||
},
|
||||
[instanceId, request, connectionId]
|
||||
);
|
||||
|
||||
const selectPath = useCallback(
|
||||
(p: string) => {
|
||||
updateParam('path', p);
|
||||
setBrowseExpanded(false);
|
||||
},
|
||||
[updateParam]
|
||||
);
|
||||
|
||||
const selectSearchQueryFromFile = useCallback(
|
||||
(p: string) => {
|
||||
updateParam('searchQuery', p);
|
||||
setFindFileBrowseExpanded(false);
|
||||
},
|
||||
[updateParam]
|
||||
);
|
||||
|
||||
const selectSourcePath = useCallback(
|
||||
(p: string) => {
|
||||
updateParam('sourcePath', p);
|
||||
setCopySourceExpanded(false);
|
||||
},
|
||||
[updateParam]
|
||||
);
|
||||
|
||||
const selectDestPath = useCallback(
|
||||
(p: string) => {
|
||||
updateParam('destPath', p);
|
||||
setCopyDestExpanded(false);
|
||||
},
|
||||
[updateParam]
|
||||
);
|
||||
|
||||
const needsSearch = nodeType === 'sharepoint.findFile';
|
||||
const needsSiteId = false;
|
||||
|
||||
const showPathFieldsForList =
|
||||
nodeType === 'sharepoint.listFiles';
|
||||
const showPathFieldsForFileUploadDownload =
|
||||
nodeType === 'sharepoint.readFile' ||
|
||||
nodeType === 'sharepoint.uploadFile' ||
|
||||
nodeType === 'sharepoint.downloadFile';
|
||||
|
||||
/** Path + browse (same tree wiring) for these types — not copyFile (copy uses its own trees). */
|
||||
const showStandardPathBrowse =
|
||||
connectionId &&
|
||||
(showPathFieldsForList || showPathFieldsForFileUploadDownload);
|
||||
|
||||
const showFindFileBrowse = connectionId && needsSearch;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Connection</label>
|
||||
<select
|
||||
value={connectionId}
|
||||
onChange={(e) => updateParam('connectionId', e.target.value)}
|
||||
disabled={connectionsLoading}
|
||||
>
|
||||
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.externalUsername ?? c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{needsSearch && (
|
||||
<div>
|
||||
<label>Search query / path</label>
|
||||
<input
|
||||
value={(params.searchQuery as string) ?? ''}
|
||||
onChange={(e) => updateParam('searchQuery', e.target.value)}
|
||||
placeholder="/sites/SiteName/Shared Documents or search term"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPathFieldsForList && (
|
||||
<div>
|
||||
<label>Folder path</label>
|
||||
<input
|
||||
value={path}
|
||||
onChange={(e) => updateParam('path', e.target.value)}
|
||||
placeholder="/ or /sites/SiteName/Shared Documents/Folder"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPathFieldsForFileUploadDownload && (
|
||||
<div>
|
||||
<label>
|
||||
{nodeType === 'sharepoint.uploadFile'
|
||||
? 'Target folder path'
|
||||
: nodeType === 'sharepoint.downloadFile'
|
||||
? 'File path'
|
||||
: 'Path'}
|
||||
</label>
|
||||
<input
|
||||
value={(params.path as string) ?? (params.filePath as string) ?? ''}
|
||||
onChange={(e) => updateParam('path', e.target.value)}
|
||||
placeholder={
|
||||
nodeType === 'sharepoint.downloadFile'
|
||||
? '/sites/SiteName/Shared Documents/file.pdf'
|
||||
: nodeType === 'sharepoint.uploadFile'
|
||||
? '/sites/.../Shared Documents/TargetFolder/'
|
||||
: 'File path'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsSiteId && (
|
||||
<div>
|
||||
<label>Site ID</label>
|
||||
<input
|
||||
value={(params.siteId as string) ?? ''}
|
||||
onChange={(e) => updateParam('siteId', e.target.value)}
|
||||
placeholder="SharePoint site ID"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodeType === 'sharepoint.copyFile' && (
|
||||
<>
|
||||
<div>
|
||||
<label>Source file</label>
|
||||
<input
|
||||
value={(params.sourcePath as string) ?? ''}
|
||||
onChange={(e) => updateParam('sourcePath', e.target.value)}
|
||||
placeholder="/sites/.../folder/file.pdf"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Destination folder</label>
|
||||
<input
|
||||
value={(params.destPath as string) ?? ''}
|
||||
onChange={(e) => updateParam('destPath', e.target.value)}
|
||||
placeholder="/sites/.../target-folder/"
|
||||
/>
|
||||
</div>
|
||||
{connectionId && (
|
||||
<>
|
||||
<details
|
||||
open={copySourceExpanded}
|
||||
onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
|
||||
style={browseDetailsStyle}
|
||||
>
|
||||
<summary style={{ ...browseSummaryStyle, padding: '0.5rem 0.75rem' }}>
|
||||
<span style={{ opacity: copySourceExpanded ? 0.7 : 1 }}>📂</span>
|
||||
Quelldatei durchsuchen
|
||||
</summary>
|
||||
<div style={browseBodyStyle}>
|
||||
<SharepointBrowseTree
|
||||
rootPath="/"
|
||||
onLoadChildren={loadChildren}
|
||||
foldersOnly={false}
|
||||
onSelectFile={selectSourcePath}
|
||||
selectedPath={(params.sourcePath as string) || null}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<details
|
||||
open={copyDestExpanded}
|
||||
onToggle={(e) => setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
|
||||
style={{ ...browseDetailsStyle, marginTop: 8 }}
|
||||
>
|
||||
<summary style={{ ...browseSummaryStyle, padding: '0.5rem 0.75rem' }}>
|
||||
<span style={{ opacity: copyDestExpanded ? 0.7 : 1 }}>📂</span>
|
||||
Zielordner durchsuchen
|
||||
</summary>
|
||||
<div style={browseBodyStyle}>
|
||||
<SharepointBrowseTree
|
||||
rootPath="/"
|
||||
onLoadChildren={loadChildren}
|
||||
foldersOnly
|
||||
onSelectFile={() => {}}
|
||||
onSelectFolder={selectDestPath}
|
||||
selectedPath={(params.destPath as string) || null}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showStandardPathBrowse && (
|
||||
<details
|
||||
open={browseExpanded}
|
||||
onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
|
||||
style={browseDetailsStyle}
|
||||
>
|
||||
<summary style={browseSummaryStyle}>
|
||||
<span style={{ opacity: browseExpanded ? 0.7 : 1 }}>📂</span>
|
||||
{browsePanelTitle(nodeType)}
|
||||
</summary>
|
||||
<div style={browseBodyStyle}>
|
||||
{isFolderPickerNode(nodeType) && (
|
||||
<SharepointBrowseTree
|
||||
rootPath="/"
|
||||
onLoadChildren={loadChildren}
|
||||
foldersOnly
|
||||
onSelectFile={() => {}}
|
||||
onSelectFolder={selectPath}
|
||||
selectedPath={path || null}
|
||||
/>
|
||||
)}
|
||||
{(nodeType === 'sharepoint.readFile' || nodeType === 'sharepoint.downloadFile') && (
|
||||
<SharepointBrowseTree
|
||||
rootPath="/"
|
||||
onLoadChildren={loadChildren}
|
||||
onSelectFile={selectPath}
|
||||
selectedPath={path || null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{showFindFileBrowse && (
|
||||
<details
|
||||
open={findFileBrowseExpanded}
|
||||
onToggle={(e) => setFindFileBrowseExpanded((e.target as HTMLDetailsElement).open)}
|
||||
style={browseDetailsStyle}
|
||||
>
|
||||
<summary style={browseSummaryStyle}>
|
||||
<span style={{ opacity: findFileBrowseExpanded ? 0.7 : 1 }}>📂</span>
|
||||
{browsePanelTitle('sharepoint.findFile')}
|
||||
</summary>
|
||||
<div style={browseBodyStyle}>
|
||||
<SharepointBrowseTree
|
||||
rootPath="/"
|
||||
onLoadChildren={loadChildren}
|
||||
onSelectFile={selectSearchQueryFromFile}
|
||||
selectedPath={(params.searchQuery as string) || null}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/**
|
||||
* Upload node config – allowed file types (multi-select), max size, multiple files.
|
||||
* Uses shared fileTypeMimeMapping for option definitions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
import { getAcceptValues, parseAllowedTypes } from '../runtime/fileTypeMimeMapping';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
function buildAcceptString(allowedTypes: string[]): string {
|
||||
if (allowedTypes.length === 0) return '';
|
||||
return allowedTypes.join(',');
|
||||
}
|
||||
|
||||
/** Get HTML accept string from node config (for file input). */
|
||||
export function getAcceptStringFromConfig(config: Record<string, unknown>): string {
|
||||
const types = parseAllowedTypes(config);
|
||||
return buildAcceptString(types);
|
||||
}
|
||||
|
||||
const FILE_TYPE_CHIP_OPTIONS = getAcceptValues();
|
||||
|
||||
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const allowedTypes = parseAllowedTypes(params);
|
||||
const maxSize = (params.maxSize as number) ?? 10;
|
||||
const multiple = (params.multiple as boolean) ?? false;
|
||||
|
||||
const toggleType = (value: string) => {
|
||||
const next = allowedTypes.includes(value)
|
||||
? allowedTypes.filter((v) => v !== value)
|
||||
: [...allowedTypes, value];
|
||||
updateParam('allowedTypes', next);
|
||||
updateParam('accept', next.length ? buildAcceptString(next) : ''); // legacy compat for backend
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.uploadNodeConfig}>
|
||||
<div className={styles.configBlock}>
|
||||
<label>Erlaubte Dateitypen</label>
|
||||
<p className={styles.configHint}>
|
||||
Mehrfachauswahl möglich. Keine Auswahl = alle Typen erlaubt.
|
||||
</p>
|
||||
<div className={styles.fileTypeChips}>
|
||||
{FILE_TYPE_CHIP_OPTIONS.map((opt) => (
|
||||
<label key={opt.value} className={styles.fileTypeChip}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowedTypes.includes(opt.value)}
|
||||
onChange={() => toggleType(opt.value)}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.configBlock}>
|
||||
<label>Max. Größe (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={500}
|
||||
step={1}
|
||||
value={maxSize}
|
||||
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.configBlock}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={multiple}
|
||||
onChange={(e) => updateParam('multiple', e.target.checked)}
|
||||
/>
|
||||
Mehrere Dateien erlauben
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* Node config renderers - one per node type (input, ai, email, sharepoint, clickup).
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { NodeConfigRendererProps } from './types';
|
||||
import { FormNodeConfig } from '../form/FormNodeConfig';
|
||||
import { ApprovalNodeConfig } from './ApprovalNodeConfig';
|
||||
import { UploadNodeConfig } from './UploadNodeConfig';
|
||||
import { CommentNodeConfig } from './CommentNodeConfig';
|
||||
import { ReviewNodeConfig } from './ReviewNodeConfig';
|
||||
import { SelectionNodeConfig } from './SelectionNodeConfig';
|
||||
import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
|
||||
import { AiNodeConfig } from './AiNodeConfig';
|
||||
import { EmailNodeConfig } from './EmailNodeConfig';
|
||||
import { SharePointNodeConfig } from './SharePointNodeConfig';
|
||||
import { ClickUpNodeConfig } from './ClickUpNodeConfig';
|
||||
import { StartNodeConfig } from '../start/StartNodeConfig';
|
||||
import { IfElseNodeConfig } from '../ifElse/IfElseNodeConfig';
|
||||
import { SwitchNodeConfig } from '../switch/SwitchNodeConfig';
|
||||
import { LoopNodeConfig } from '../loop/LoopNodeConfig';
|
||||
import { FormStartNodeConfig } from '../start/FormStartNodeConfig';
|
||||
import { ScheduleStartNodeConfig } from '../start/ScheduleStartNodeConfig';
|
||||
import { FileCreateNodeConfig } from './FileCreateNodeConfig';
|
||||
|
||||
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
|
||||
|
||||
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
||||
'trigger.manual': StartNodeConfig,
|
||||
'trigger.form': FormStartNodeConfig,
|
||||
'trigger.schedule': ScheduleStartNodeConfig,
|
||||
'input.form': FormNodeConfig,
|
||||
'input.approval': ApprovalNodeConfig,
|
||||
'input.upload': UploadNodeConfig,
|
||||
'input.comment': CommentNodeConfig,
|
||||
'input.review': ReviewNodeConfig,
|
||||
'input.selection': SelectionNodeConfig,
|
||||
'input.confirmation': ConfirmationNodeConfig,
|
||||
'ai.prompt': AiNodeConfig,
|
||||
'ai.webResearch': AiNodeConfig,
|
||||
'ai.summarizeDocument': AiNodeConfig,
|
||||
'ai.translateDocument': AiNodeConfig,
|
||||
'ai.convertDocument': AiNodeConfig,
|
||||
'ai.generateDocument': AiNodeConfig,
|
||||
'ai.generateCode': AiNodeConfig,
|
||||
'file.create': FileCreateNodeConfig,
|
||||
'email.checkEmail': EmailNodeConfig,
|
||||
'email.searchEmail': EmailNodeConfig,
|
||||
'email.draftEmail': EmailNodeConfig,
|
||||
'sharepoint.findFile': SharePointNodeConfig,
|
||||
'sharepoint.readFile': SharePointNodeConfig,
|
||||
'sharepoint.uploadFile': SharePointNodeConfig,
|
||||
'sharepoint.listFiles': SharePointNodeConfig,
|
||||
'sharepoint.downloadFile': SharePointNodeConfig,
|
||||
'sharepoint.copyFile': SharePointNodeConfig,
|
||||
'clickup.searchTasks': ClickUpNodeConfig,
|
||||
'clickup.listTasks': ClickUpNodeConfig,
|
||||
'clickup.getTask': ClickUpNodeConfig,
|
||||
'clickup.createTask': ClickUpNodeConfig,
|
||||
'clickup.updateTask': ClickUpNodeConfig,
|
||||
'clickup.uploadAttachment': ClickUpNodeConfig,
|
||||
'flow.ifElse': IfElseNodeConfig,
|
||||
'flow.switch': SwitchNodeConfig,
|
||||
'flow.loop': LoopNodeConfig,
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
export type { NodeConfigRendererProps, FormField } from '../shared/types';
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Data Picker for selecting node output references.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createRef, type DataRef } from './dataRef';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
interface DataPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (ref: DataRef) => void;
|
||||
availableSourceIds: string[];
|
||||
nodes: Array<{ id: string; title?: string; type?: string }>;
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
getNodeLabel: (node: { id: string; title?: string }) => string;
|
||||
}
|
||||
|
||||
/** Collect all pickable paths (each leads to a value the user can reference) */
|
||||
function buildPickablePaths(obj: unknown, basePath: (string | number)[] = []): Array<{ path: (string | number)[]; label: string }> {
|
||||
const pathLabel = basePath.length ? basePath.map(String).join(' → ') : '(ganze Ausgabe)';
|
||||
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
|
||||
return [{ path: [...basePath], label: pathLabel }];
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
|
||||
for (let i = 0; i < Math.min(obj.length, 10); i++) {
|
||||
result.push(...buildPickablePaths(obj[i], [...basePath, i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
result.push(...buildPickablePaths(v, [...basePath, k]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return [{ path: [...basePath], label: pathLabel }];
|
||||
}
|
||||
|
||||
export const DataPicker: React.FC<DataPickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
availableSourceIds,
|
||||
nodes,
|
||||
nodeOutputsPreview,
|
||||
getNodeLabel,
|
||||
}) => {
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggleExpand = (nodeId: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePick = (nodeId: string, path: (string | number)[]) => {
|
||||
onPick(createRef(nodeId, path));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dataPickerOverlay} onClick={onClose}>
|
||||
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.dataPickerHeader}>
|
||||
<h4 className={styles.dataPickerTitle}>Datenquelle wählen</h4>
|
||||
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label="Schließen">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.dataPickerBody}>
|
||||
{(() => {
|
||||
const filteredIds = availableSourceIds.filter((nodeId) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
return node?.type !== 'trigger.manual';
|
||||
});
|
||||
if (filteredIds.length === 0) {
|
||||
return <p className={styles.dataPickerEmpty}>Keine vorherigen Nodes verfügbar.</p>;
|
||||
}
|
||||
return filteredIds.map((nodeId) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const preview = nodeOutputsPreview[nodeId];
|
||||
const label = node ? getNodeLabel(node) : nodeId;
|
||||
const paths = buildPickablePaths(preview);
|
||||
const isExpanded = expandedNodes.has(nodeId);
|
||||
|
||||
return (
|
||||
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dataPickerNodeHeader}
|
||||
onClick={() => toggleExpand(nodeId)}
|
||||
>
|
||||
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
|
||||
<span className={styles.dataPickerNodeLabel}>{label}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
{paths.map((p, i) => (
|
||||
<button
|
||||
key={`${p.path.join('.')}-${i}`}
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
onClick={() => handlePick(nodeId, p.path)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Output preview builders per node type.
|
||||
* Derives example output trees from node parameters for Data Picker.
|
||||
* Extensible: register builders for new node types without changing core logic.
|
||||
*/
|
||||
|
||||
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||
|
||||
export type OutputPreviewBuilder = (node: CanvasNode) => unknown;
|
||||
|
||||
const builders: Record<string, OutputPreviewBuilder> = {};
|
||||
|
||||
function parseFormFields(
|
||||
params: Record<string, unknown>
|
||||
): Array<{ name: string; type?: string }> {
|
||||
const raw = params.formFields ?? params.fields;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((f, i) => {
|
||||
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
||||
const o = f as Record<string, unknown>;
|
||||
return {
|
||||
name: String(o.name ?? `field${i + 1}`),
|
||||
type: typeof o.type === 'string' ? o.type : undefined,
|
||||
};
|
||||
}
|
||||
return { name: `field${i + 1}` };
|
||||
});
|
||||
}
|
||||
|
||||
function runEnvelopeBase(): Record<string, unknown> {
|
||||
return {
|
||||
trigger: { type: 'manual' },
|
||||
payload: {},
|
||||
context: {},
|
||||
files: [],
|
||||
user: {},
|
||||
metadata: {},
|
||||
raw: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Register a builder for a node type id (exact match) or prefix (use '*' suffix) */
|
||||
export function registerOutputPreview(typeIdOrPrefix: string, builder: OutputPreviewBuilder): void {
|
||||
builders[typeIdOrPrefix] = builder;
|
||||
}
|
||||
|
||||
/** Build preview for a single node; returns {} for unknown types */
|
||||
export function buildNodeOutputPreview(node: CanvasNode): unknown {
|
||||
const exact = builders[node.type];
|
||||
if (exact) return exact(node);
|
||||
|
||||
const prefix = node.type.split('.')[0];
|
||||
const prefixBuilder = builders[`${prefix}.*`];
|
||||
if (prefixBuilder) return prefixBuilder(node);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Build full nodeOutputsPreview map from graph */
|
||||
export function buildNodeOutputsPreview(
|
||||
nodes: CanvasNode[],
|
||||
nodeOutputsFromRun?: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const n of nodes) {
|
||||
const fromRun = nodeOutputsFromRun?.[n.id];
|
||||
if (fromRun !== undefined) {
|
||||
result[n.id] = fromRun;
|
||||
} else {
|
||||
result[n.id] = buildNodeOutputPreview(n);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Built-in builders (extensible, no hardcoding in core) ----
|
||||
|
||||
registerOutputPreview('trigger.manual', () => runEnvelopeBase());
|
||||
registerOutputPreview('trigger.schedule', () => runEnvelopeBase());
|
||||
|
||||
registerOutputPreview('trigger.form', (node) => {
|
||||
const params = node.parameters ?? {};
|
||||
const fields = parseFormFields(params);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (f.type === 'clickup_tasks') {
|
||||
payload[f.name] = { add: ['…'], rem: [] };
|
||||
} else {
|
||||
payload[f.name] = '';
|
||||
}
|
||||
}
|
||||
return { ...runEnvelopeBase(), payload };
|
||||
});
|
||||
|
||||
registerOutputPreview('input.form', (node) => {
|
||||
const params = node.parameters ?? {};
|
||||
const fields = parseFormFields(params);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (f.type === 'clickup_tasks') {
|
||||
payload[f.name] = '';
|
||||
} else {
|
||||
payload[f.name] = '';
|
||||
}
|
||||
}
|
||||
/** Nur payload — kein Spread der Keys nach oben (vermeidet doppelte / verwirrende Pfade im Data Picker). */
|
||||
return { payload };
|
||||
});
|
||||
|
||||
registerOutputPreview('input.upload', () => ({
|
||||
file: { id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' },
|
||||
files: [{ id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' }],
|
||||
fileIds: ['...'],
|
||||
}));
|
||||
|
||||
registerOutputPreview('ai.*', () => ({ prompt: '...', context: '...', result: '...' }));
|
||||
registerOutputPreview('file.*', () => ({
|
||||
documents: [{ documentName: '...', documentData: '...' }],
|
||||
documentList: [{ documentName: '...', documentData: '...' }],
|
||||
}));
|
||||
registerOutputPreview('email.*', () => ({ subject: '...', body: '...' }));
|
||||
registerOutputPreview('email.searchEmail', () => ({
|
||||
data: { searchResults: { results: [{ subject: '...', from: '...' }] } },
|
||||
}));
|
||||
registerOutputPreview('email.checkEmail', () => ({
|
||||
data: { emails: { emails: [{ subject: '...', from: '...' }] } },
|
||||
}));
|
||||
registerOutputPreview('sharepoint.*', () => ({ file: { url: '...', name: '...' } }));
|
||||
registerOutputPreview('sharepoint.listFiles', () => ({
|
||||
files: [{ url: '...', name: '...' }],
|
||||
}));
|
||||
registerOutputPreview('clickup.createTask', () => ({
|
||||
success: true,
|
||||
taskId: '…',
|
||||
clickupTask: { id: '…', name: '…' },
|
||||
documents: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
|
||||
documentList: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
|
||||
}));
|
||||
|
||||
registerOutputPreview('clickup.*', () => ({
|
||||
success: true,
|
||||
taskId: '…',
|
||||
clickupTask: { id: '…' },
|
||||
documents: [{ documentName: 'clickup_result.json', documentData: '{}' }],
|
||||
}));
|
||||
registerOutputPreview('flow.ifElse', () => ({ branch: 0, conditionResult: true, input: {} }));
|
||||
registerOutputPreview('flow.switch', () => ({ match: 0, value: '...' }));
|
||||
registerOutputPreview('flow.loop', () => ({
|
||||
items: [],
|
||||
count: 0,
|
||||
currentItem: { name: 'field', value: '...' },
|
||||
currentIndex: 0,
|
||||
}));
|
||||
|
|
@ -1,946 +0,0 @@
|
|||
/**
|
||||
* AutomationEditor Styles
|
||||
*
|
||||
* Full-screen editor with form on left and actions panel on right
|
||||
*/
|
||||
|
||||
/* Used when AutomationEditor had custom overlay - kept for reference, Popup is used now */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Popup customisation for fullscreen editor - fill content area */
|
||||
.editorPopup :global([class*="content"]) {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editorContainer {
|
||||
background: var(--surface-color, #ffffff);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,2 +0,0 @@
|
|||
export { AutomationEditor, type AutomationEditorProps, type EditorMode } from './AutomationEditor';
|
||||
export { default } from './AutomationEditor';
|
||||
105
src/components/Chat/ChatInput.tsx
Normal file
105
src/components/Chat/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* ChatInput -- Shared chat input component.
|
||||
*
|
||||
* Simple text input with send button, usable by both Workspace and Editor.
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
isProcessing?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onSend,
|
||||
isProcessing,
|
||||
placeholder,
|
||||
disabled,
|
||||
autoFocus = true,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const resolvedPlaceholder = placeholder ?? t('Nachricht eingeben…');
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
|
||||
const _handleSend = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isProcessing || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue('');
|
||||
}, [value, isProcessing, disabled, onSend]);
|
||||
|
||||
const _handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
_handleSend();
|
||||
}
|
||||
},
|
||||
[_handleSend]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||
alignItems: 'flex-end',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={_handleKeyDown}
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={isProcessing || disabled}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
resize: 'none',
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
minHeight: '36px',
|
||||
maxHeight: '120px',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
color: 'var(--text-primary, #333)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={_handleSend}
|
||||
disabled={!value.trim() || isProcessing || disabled}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: !value.trim() || isProcessing || disabled ? '#ccc' : 'var(--color-primary, #2563eb)',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
cursor: !value.trim() || isProcessing || disabled ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isProcessing ? '…' : t('Senden')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
src/components/Chat/ChatMessageList.tsx
Normal file
92
src/components/Chat/ChatMessageList.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* ChatMessageList -- Shared chat message display component.
|
||||
*
|
||||
* Renders a scrollable list of messages with Markdown support.
|
||||
* Used by both the Workspace ChatStream and the Editor ChatPanel.
|
||||
*/
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isProcessing?: boolean;
|
||||
emptyMessage?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const _roleColors: Record<string, string> = {
|
||||
user: 'var(--color-primary, #2563eb)',
|
||||
assistant: 'var(--text-primary, #333)',
|
||||
system: 'var(--text-secondary, #888)',
|
||||
};
|
||||
|
||||
export const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
messages,
|
||||
isProcessing,
|
||||
emptyMessage,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const resolvedEmpty = emptyMessage ?? t('Noch keine Nachrichten.');
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px', textAlign: 'center', marginTop: '24px' }}>
|
||||
{resolvedEmpty}
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
background: msg.role === 'user' ? 'var(--bg-secondary, #f5f5f5)' : 'transparent',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5,
|
||||
color: _roleColors[msg.role] || 'var(--text-primary, #333)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: '11px', marginBottom: '4px', textTransform: 'uppercase', color: 'var(--text-secondary, #888)' }}>
|
||||
{msg.role}
|
||||
</div>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
))}
|
||||
{isProcessing && (
|
||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '12px', fontStyle: 'italic' }}>
|
||||
{t('Wird verarbeitet…')}
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
src/components/Chat/index.ts
Normal file
3
src/components/Chat/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ChatMessageList } from './ChatMessageList';
|
||||
export type { ChatMessage } from './ChatMessageList';
|
||||
export { ChatInput } from './ChatInput';
|
||||
|
|
@ -61,11 +61,11 @@ export function ContentPreview({
|
|||
if (isOpen && fileId) {
|
||||
// Check if we have valid data
|
||||
if (!fileId || fileId === 'undefined' || fileId === 'null') {
|
||||
setError('Invalid file ID');
|
||||
setError(t('Ungültige Datei-ID'));
|
||||
return;
|
||||
}
|
||||
if (!fileName || fileName === 'Unknown Item') {
|
||||
setError('File name not available');
|
||||
if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') {
|
||||
setError(t('Dateiname nicht verfügbar'));
|
||||
return;
|
||||
}
|
||||
loadPreview();
|
||||
|
|
@ -77,7 +77,7 @@ export function ContentPreview({
|
|||
}
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, fileId, fileName]);
|
||||
}, [isOpen, fileId, fileName, t]);
|
||||
|
||||
|
||||
const loadPreview = async () => {
|
||||
|
|
@ -95,10 +95,10 @@ export function ContentPreview({
|
|||
}
|
||||
// If it's text content but MIME type says PDF, we'll handle it in renderPreview
|
||||
} else {
|
||||
setError(result.error || 'Failed to load preview');
|
||||
setError(result.error || t('Vorschau konnte nicht geladen werden.'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred while loading the preview');
|
||||
setError(t('Ein unerwarteter Fehler ist aufgetreten, während'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ export function ContentPreview({
|
|||
const actions: PopupAction[] = [
|
||||
// Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs
|
||||
...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
|
||||
label: copySuccess ? t('files.preview.copied', 'Copied!') : t(''),
|
||||
label: copySuccess ? t('In die Zwischenablage kopiert') : t(''),
|
||||
icon: copySuccess ? '✓' : <IoIosCopy />,
|
||||
onClick: handleCopyContent,
|
||||
disabled: !previewContent && !previewUrl,
|
||||
|
|
@ -168,7 +168,7 @@ export function ContentPreview({
|
|||
previewUrl={undefined}
|
||||
previewContent={previewContent}
|
||||
fileName={fileName}
|
||||
onError={() => setError('Failed to load PDF preview')}
|
||||
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -194,14 +194,14 @@ export function ContentPreview({
|
|||
return (
|
||||
<div className={styles.jsonContainer}>
|
||||
<div className={styles.jsonHeader}>
|
||||
<span className={styles.jsonTitle}>JSON Preview (Fallback)</span>
|
||||
<span className={styles.jsonTitle}>{t('JSON-Vorschau als Fallback')}</span>
|
||||
<div className={styles.jsonHeaderRight}>
|
||||
<span className={styles.jsonSize}>Raw content</span>
|
||||
<span className={styles.jsonSize}>{t('Rohinhalt')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre className={styles.jsonPreview}>
|
||||
<code className={styles.jsonCode}>
|
||||
{previewContent || 'No content available'}
|
||||
{previewContent || t('Kein Inhalt verfügbar')}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -219,7 +219,7 @@ export function ContentPreview({
|
|||
<ImageRenderer
|
||||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
onError={() => setError('Failed to load image preview')}
|
||||
onError={() => setError(t('Bildvorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ export function ContentPreview({
|
|||
<HtmlRenderer
|
||||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
onError={() => setError('Failed to load HTML preview')}
|
||||
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -240,7 +240,7 @@ export function ContentPreview({
|
|||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
mimeType={mimeType}
|
||||
onError={() => setError('Failed to load text preview')}
|
||||
onError={() => setError(t('Textvorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ export function ContentPreview({
|
|||
previewUrl={previewUrl}
|
||||
previewContent={previewContent || undefined}
|
||||
fileName={fileName}
|
||||
onError={() => setError('Failed to load PDF preview')}
|
||||
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -267,7 +267,7 @@ export function ContentPreview({
|
|||
<HtmlRenderer
|
||||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
onError={() => setError('Failed to load HTML preview')}
|
||||
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -279,7 +279,7 @@ export function ContentPreview({
|
|||
previewUrl={previewUrl}
|
||||
fileName={fileName}
|
||||
mimeType={mimeType}
|
||||
onError={() => setError('Preview not supported for this file type')}
|
||||
onError={() => setError(t('Vorschau wird für dieses Format nicht unterstützt'))}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -292,7 +292,7 @@ export function ContentPreview({
|
|||
<Popup
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
||||
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||
size="fullscreen"
|
||||
className={styles.contentPreviewPopup}
|
||||
actions={actions}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export function UrlContentPreview({
|
|||
}
|
||||
// If PDF.js also fails, show error
|
||||
setIsLoading(false);
|
||||
setError('Failed to load PDF. This might be due to CORS restrictions. You can try downloading the file or opening it in a new tab.');
|
||||
setError(t('Fehler beim Laden des PDFs'));
|
||||
setShowPdfAnyway(true);
|
||||
};
|
||||
|
||||
|
|
@ -96,7 +96,9 @@ export function UrlContentPreview({
|
|||
|
||||
const warningTimeout = setTimeout(() => {
|
||||
if (isLoading && !hasLoaded) {
|
||||
setWarning('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.');
|
||||
setWarning(
|
||||
t('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.')
|
||||
);
|
||||
// Don't set isLoading to false - let it continue
|
||||
}
|
||||
}, WARNING_TIMEOUT);
|
||||
|
|
@ -107,11 +109,11 @@ export function UrlContentPreview({
|
|||
console.log('PDF loading timeout, switching to PDF.js fallback');
|
||||
setUsePdfJs(true);
|
||||
setIsLoading(true); // Restart loading with PDF.js
|
||||
setWarning('PDF lädt langsam. Versuche alternative Anzeigemethode...');
|
||||
setWarning(t('PDF lädt langsam. Alternative Anzeigemethode wird versucht…'));
|
||||
} else if (isLoading && !hasLoaded && usePdfJs) {
|
||||
// PDF.js also failed, show error
|
||||
setShowPdfAnyway(true);
|
||||
setError('PDF lädt langsam. Bitte verwenden Sie den Download-Button oder öffnen Sie es in einem neuen Tab.');
|
||||
setError(t('PDF lädt langsam, bitte verwenden'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, QUICK_TIMEOUT);
|
||||
|
|
@ -121,7 +123,7 @@ export function UrlContentPreview({
|
|||
clearTimeout(errorTimeout);
|
||||
};
|
||||
}
|
||||
}, [isOpen, isLoading, hasLoaded, usePdfJs]);
|
||||
}, [isOpen, isLoading, hasLoaded, usePdfJs, t]);
|
||||
|
||||
// Validate URL
|
||||
useEffect(() => {
|
||||
|
|
@ -129,7 +131,7 @@ export function UrlContentPreview({
|
|||
try {
|
||||
new URL(url);
|
||||
} catch (e) {
|
||||
setError('Invalid URL');
|
||||
setError(t('Ungültige URL'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -184,7 +186,7 @@ export function UrlContentPreview({
|
|||
padding: '0.5rem 1rem'
|
||||
}}
|
||||
>
|
||||
In neuem Tab öffnen
|
||||
{t('In neuem Tab öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
|
|
@ -195,7 +197,7 @@ export function UrlContentPreview({
|
|||
padding: '0.5rem 1rem'
|
||||
}}
|
||||
>
|
||||
Download
|
||||
{t('Herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -229,7 +231,7 @@ export function UrlContentPreview({
|
|||
}}
|
||||
className={styles.retryButton}
|
||||
>
|
||||
{t('common.retry', 'Retry')}
|
||||
{t('Wiederholen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
|
|
@ -241,7 +243,7 @@ export function UrlContentPreview({
|
|||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
In neuem Tab öffnen
|
||||
{t('In neuem Tab öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
|
|
@ -253,7 +255,7 @@ export function UrlContentPreview({
|
|||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Download File
|
||||
{t('Datei herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -284,7 +286,7 @@ export function UrlContentPreview({
|
|||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
In neuem Tab öffnen
|
||||
{t('In neuem Tab öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
|
|
@ -296,7 +298,7 @@ export function UrlContentPreview({
|
|||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Download
|
||||
{t('Herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -314,9 +316,9 @@ export function UrlContentPreview({
|
|||
<div className={styles.unsupportedContainer}>
|
||||
<div className={styles.unsupportedIcon}>📄</div>
|
||||
<div className={styles.fileName}>{fileName}</div>
|
||||
<p>Preview not supported for this file type. Please download the file to view it.</p>
|
||||
<p>{t('Vorschau wird hierfür nicht unterstützt')}</p>
|
||||
<button onClick={handleDownload} className={styles.retryButton}>
|
||||
Download File
|
||||
{t('Datei herunterladen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -329,7 +331,7 @@ export function UrlContentPreview({
|
|||
<Popup
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
||||
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||
size="fullscreen"
|
||||
className={styles.contentPreviewPopup}
|
||||
actions={actions}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
|
|||
onClick={onRetry}
|
||||
className={styles.retryButton}
|
||||
>
|
||||
{t('common.retry', 'Retry')}
|
||||
{t('Wiederholen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
|||
<button
|
||||
className={styles.collapseButton}
|
||||
onClick={() => toggleCollapse(rowPath)}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
title={isCollapsed ? t('Aufklappen') : t('Einklappen')}
|
||||
>
|
||||
{isCollapsed ? '▶' : '▼'}
|
||||
</button>
|
||||
|
|
@ -339,7 +339,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
|||
) : (
|
||||
typeof data.values[index] === 'object' && data.values[index] !== null && 'keys' in data.values[index] ?
|
||||
renderTable(data.values[index], level + 1, rowPath) :
|
||||
<span className={styles.jsonValue}>Error: Invalid nested data</span>
|
||||
<span className={styles.jsonValue}>{t('Fehler: Ungültige verschachtelte Daten')}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -471,7 +471,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
|||
<div className={styles.jsonContainer}>
|
||||
<div className={styles.jsonHeader}>
|
||||
<div className={styles.jsonHeaderRight}>
|
||||
<span className={styles.jsonSize}>{preprocessedData.keys.length} {t('files.preview.json.properties', 'properties')}</span>
|
||||
<span className={styles.jsonSize}>{preprocessedData.keys.length} {t('Eigenschaften')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderTable(preprocessedData, 0, 'root')}
|
||||
|
|
@ -479,7 +479,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
|||
);
|
||||
} catch (parseError) {
|
||||
const rawData = {
|
||||
keys: ['Raw Content'],
|
||||
keys: [t('Rohinhalt')],
|
||||
values: [previewContent],
|
||||
types: ['string'],
|
||||
isNested: [false]
|
||||
|
|
@ -488,14 +488,14 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
|||
return (
|
||||
<div className={styles.jsonContainer}>
|
||||
<div className={styles.jsonHeader}>
|
||||
<span className={styles.jsonTitle}>{t('files.preview.json.invalid', 'Raw Content (Invalid JSON)')}: {fileName}</span>
|
||||
<span className={styles.jsonTitle}>{t('Ungültiges JSON')}: {fileName}</span>
|
||||
<div className={styles.jsonHeaderRight}>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopyJson}
|
||||
title={t('files.preview.json.copyRaw', 'Copy content to clipboard')}
|
||||
title={t('Rohtext in die Zwischenablage kopieren')}
|
||||
>
|
||||
📋 {t('common.copy', 'Copy')}
|
||||
📋 {t('Kopieren')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export function LoadingRenderer() {
|
|||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>{t('files.preview.loading', 'Loading preview...')}</p>
|
||||
<p>{t('Vorschau wird geladen...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react';
|
|||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
// Set worker source for PDF.js
|
||||
if (typeof window !== 'undefined') {
|
||||
// Try to use local worker first, fallback to CDN
|
||||
|
|
@ -24,7 +26,9 @@ interface PdfJsRendererProps {
|
|||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) {
|
||||
export function PdfJsRenderer({
|
||||
previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -62,7 +66,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
|
|||
} catch (err) {
|
||||
console.error('Error loading PDF with PDF.js:', err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load PDF');
|
||||
setError(err instanceof Error ? err.message : t('PDF konnte nicht geladen werden.'));
|
||||
setIsLoading(false);
|
||||
onError();
|
||||
}
|
||||
|
|
@ -112,7 +116,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
|
|||
} catch (err) {
|
||||
console.error('Error rendering PDF page:', err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to render PDF page');
|
||||
setError(err instanceof Error ? err.message : t('PDF-Seite konnte nicht gerendert werden.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -128,7 +132,9 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
|
|||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorIcon}>⚠️</div>
|
||||
<p>Fehler beim Laden der PDF: {error}</p>
|
||||
<p>
|
||||
{t('Fehler beim Laden der PDF:')} {error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -137,7 +143,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
|
|||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>PDF wird geladen...</p>
|
||||
<p>{t('PDF wird geladen')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: P
|
|||
<div className={styles.warningMessage}>
|
||||
<span className={styles.warningIcon}><IoIosWarning /></span>
|
||||
<span className={styles.warningText}>
|
||||
{t('files.preview.pdfFileCorrupted', 'This file appears to be corrupted. It has a PDF extension but contains text content. Please re-upload the file if possible.')}
|
||||
{t('Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
// Updated to handle both previewUrl and previewContent
|
||||
|
||||
interface TextRendererProps {
|
||||
|
|
@ -10,13 +12,15 @@ interface TextRendererProps {
|
|||
onError: () => void;
|
||||
}
|
||||
|
||||
export function TextRenderer({ previewUrl, previewContent, fileName, mimeType, onError }: TextRendererProps) {
|
||||
export function TextRenderer({
|
||||
previewUrl, previewContent, fileName, mimeType, onError }: TextRendererProps) {
|
||||
const { t } = useLanguage();
|
||||
// If we have previewContent directly, display it as text
|
||||
if (previewContent && !previewUrl) {
|
||||
return (
|
||||
<div className={styles.textContainer}>
|
||||
<div className={styles.textHeader}>
|
||||
<span className={styles.textTitle}>Text Preview</span>
|
||||
<span className={styles.textTitle}>{t('Textvorschau')}</span>
|
||||
</div>
|
||||
<pre className={styles.textPreview}>
|
||||
<code className={styles.textCode}>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendere
|
|||
return (
|
||||
<div className={styles.unsupportedContainer}>
|
||||
<div className={styles.unsupportedIcon}>📄</div>
|
||||
<p>{t('files.preview.unsupported', 'Preview not available for this file type')}</p>
|
||||
<p>{t('Vorschau für diesen Dateityp nicht verfügbar')}</p>
|
||||
<p className={styles.fileName}>{fileName}</p>
|
||||
<a
|
||||
href={previewUrl}
|
||||
download={fileName}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
{t('files.action.download', 'Download')}
|
||||
{t('Herunterladen')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
|
||||
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||
import type { NodeType } from '../../../api/automation2Api';
|
||||
import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||
|
||||
export interface Automation2DataFlowContextValue {
|
||||
currentNodeId: string;
|
||||
|
|
@ -14,6 +15,8 @@ export interface Automation2DataFlowContextValue {
|
|||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog: Record<string, PortSchema>;
|
||||
systemVariables: Record<string, SystemVariable>;
|
||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||
getAvailableSourceIds: () => string[];
|
||||
}
|
||||
|
|
@ -31,6 +34,8 @@ interface Automation2DataFlowProviderProps {
|
|||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +46,8 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog = {},
|
||||
systemVariables = {},
|
||||
children,
|
||||
}) => {
|
||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||
|
|
@ -52,11 +59,13 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog,
|
||||
systemVariables,
|
||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||
n.title ?? n.label ?? n.type ?? n.id,
|
||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||
};
|
||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language]);
|
||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
|
||||
|
||||
return (
|
||||
<Automation2DataFlowContext.Provider value={value}>
|
||||
|
|
@ -14,13 +14,29 @@
|
|||
SIDEBAR - Node List
|
||||
============================================================================= */
|
||||
|
||||
.sidebar {
|
||||
.resizeDivider {
|
||||
flex-shrink: 0;
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
background: var(--border-color, #e0e0e0);
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.resizeDivider:hover,
|
||||
.resizeDivider:active {
|
||||
background: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
border-left: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +124,7 @@
|
|||
cursor: grab;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nodeItem:hover {
|
||||
|
|
@ -151,6 +168,29 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nodeItem .nodeItemTooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
z-index: 100;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #333);
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
max-width: 280px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodeItem:hover .nodeItemTooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Loading / Error */
|
||||
.loading,
|
||||
.error {
|
||||
|
|
@ -318,6 +358,19 @@
|
|||
box-shadow: 0 0 0 2px var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.canvasNodeHighlighted {
|
||||
transition: border-color 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.canvasNodeHighlighted[style*="box-shadow"] {
|
||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.canvasNodeContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -360,6 +413,39 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.canvasNodeComment {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #999);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.canvasNode .canvasNodeCommentTooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 100;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #333);
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
max-width: 260px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvasNode:hover .canvasNodeCommentTooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvasNodeInput {
|
||||
width: 100%;
|
||||
padding: 0.15rem 0.25rem;
|
||||
|
|
@ -446,6 +532,13 @@
|
|||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
.nodeConfigDescription {
|
||||
margin: -0.5rem 0 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nodeConfigPanel label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
|
|
@ -713,13 +806,13 @@
|
|||
.scheduleModeBlock {
|
||||
position: relative;
|
||||
/* Ausgewählte Karte (orange) + Text auf „An“-Chips im erweiterten Bereich */
|
||||
--schedule-active: var(--schedule-mode-active, var(--color-secondary, #f25843));
|
||||
--schedule-active-border: var(--schedule-mode-active-border, var(--color-text, #3a3a3a));
|
||||
--schedule-active: var(--schedule-mode-active, var(--color-secondary));
|
||||
--schedule-active-border: var(--schedule-mode-active-border, var(--color-text));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-radius: 25px;
|
||||
border: 1px solid var(--color-text, #ddd);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #E2E8F0);
|
||||
background-color: var(--bg-primary, #fff);
|
||||
color: var(--color-text, #222);
|
||||
overflow: hidden;
|
||||
|
|
@ -1451,3 +1544,33 @@
|
|||
border-color: var(--primary-color, #007bff);
|
||||
color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
/* Right panel tab bar (Nodes / Tracing) */
|
||||
.rightTabBar {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.rightTab {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.rightTab:hover {
|
||||
background: var(--bg-hover, #f0f0f0);
|
||||
}
|
||||
|
||||
.rightTabActive {
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
color: var(--text-primary, #333);
|
||||
box-shadow: inset 0 -2px 0 var(--primary-color, #007bff);
|
||||
}
|
||||
805
src/components/FlowEditor/editor/Automation2FlowEditor.tsx
Normal file
805
src/components/FlowEditor/editor/Automation2FlowEditor.tsx
Normal file
|
|
@ -0,0 +1,805 @@
|
|||
/**
|
||||
* Automation2FlowEditor
|
||||
*
|
||||
* n8n-style flow builder with backend-driven node list.
|
||||
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import {
|
||||
fetchNodeTypes,
|
||||
executeGraph,
|
||||
fetchWorkflows,
|
||||
fetchWorkflow,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
fetchVersions,
|
||||
createDraftVersion,
|
||||
publishVersion,
|
||||
unpublishVersion,
|
||||
archiveVersion,
|
||||
createTemplateFromWorkflow,
|
||||
copyTemplate,
|
||||
type NodeType,
|
||||
type NodeTypeCategory,
|
||||
type Automation2Graph,
|
||||
type Automation2Workflow,
|
||||
type ExecuteGraphResponse,
|
||||
type WorkflowEntryPoint,
|
||||
type AutoVersion,
|
||||
type AutoTemplateScope,
|
||||
} from '../../../api/workflowApi';
|
||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||
import { NodeSidebar } from './NodeSidebar';
|
||||
import { CanvasHeader } from './CanvasHeader';
|
||||
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
||||
import { TemplatePicker } from './TemplatePicker';
|
||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
||||
import {
|
||||
syncCanvasStartNode,
|
||||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import { EditorChatPanel } from './EditorChatPanel';
|
||||
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
||||
import { RunTracingPanel } from './RunTracingPanel';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
|
||||
buildInvocationsForPrimaryKind('manual', [], runLabel);
|
||||
|
||||
interface Automation2FlowEditorProps {
|
||||
instanceId: string;
|
||||
mandateId?: string;
|
||||
language?: string;
|
||||
/** When set, load this workflow on mount (e.g. from workflows list edit) */
|
||||
initialWorkflowId?: string | null;
|
||||
pendingFiles?: PendingFile[];
|
||||
onRemovePendingFile?: (fileId: string) => void;
|
||||
dataSources?: EditorDataSource[];
|
||||
featureDataSources?: EditorFeatureDataSource[];
|
||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||
onSourcesChanged?: () => void;
|
||||
}
|
||||
|
||||
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
|
||||
mandateId,
|
||||
language = 'de',
|
||||
initialWorkflowId,
|
||||
pendingFiles,
|
||||
onRemovePendingFile,
|
||||
dataSources,
|
||||
featureDataSources,
|
||||
onFileSelect,
|
||||
onSourcesChanged,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
||||
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
||||
);
|
||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
|
||||
_buildDefaultInvocations(t('Jetzt ausführen'))
|
||||
);
|
||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
||||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
||||
|
||||
const udbContext: UdbContext = useMemo(() => ({
|
||||
instanceId,
|
||||
mandateId: mandateId || '',
|
||||
featureInstanceId: instanceId,
|
||||
}), [instanceId, mandateId]);
|
||||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||
const [versionLoading, setVersionLoading] = useState(false);
|
||||
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
||||
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
||||
});
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
|
||||
});
|
||||
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const _onMouseMove = (e: MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const { target, startX, startW } = resizingRef.current;
|
||||
const delta = e.clientX - startX;
|
||||
if (target === 'left') {
|
||||
setLeftPanelWidth(Math.max(240, Math.min(600, startW + delta)));
|
||||
} else {
|
||||
setSidebarWidth(Math.max(200, Math.min(500, startW - delta)));
|
||||
}
|
||||
};
|
||||
const _onMouseUp = () => {
|
||||
if (!resizingRef.current) return;
|
||||
const { target } = resizingRef.current;
|
||||
resizingRef.current = null;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
if (target === 'left') {
|
||||
setLeftPanelWidth((w) => { try { localStorage.setItem('flowEditor.leftPanelWidth', String(w)); } catch {} return w; });
|
||||
} else {
|
||||
setSidebarWidth((w) => { try { localStorage.setItem('flowEditor.sidebarWidth', String(w)); } catch {} return w; });
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', _onMouseMove);
|
||||
document.addEventListener('mouseup', _onMouseUp);
|
||||
return () => { document.removeEventListener('mousemove', _onMouseMove); document.removeEventListener('mouseup', _onMouseUp); };
|
||||
}, []);
|
||||
|
||||
const _startResize = useCallback((target: 'left' | 'right', e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizingRef.current = { target, startX: e.clientX, startW: target === 'left' ? leftPanelWidth : sidebarWidth };
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [leftPanelWidth, sidebarWidth]);
|
||||
|
||||
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
|
||||
|
||||
const nodeOutputsPreview = useMemo(
|
||||
() =>
|
||||
buildNodeOutputsPreview(canvasNodes, nodeTypes, executeResult?.nodeOutputs as Record<string, unknown> | undefined),
|
||||
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
|
||||
);
|
||||
|
||||
const applyGraphWithSync = useCallback(
|
||||
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
||||
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
||||
setInvocations(inv);
|
||||
if (!graph?.nodes?.length) {
|
||||
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
|
||||
setCanvasNodes(synced.nodes);
|
||||
setCanvasConnections(synced.connections);
|
||||
return;
|
||||
}
|
||||
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
||||
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
|
||||
setCanvasNodes(synced.nodes);
|
||||
setCanvasConnections(synced.connections);
|
||||
},
|
||||
[nodeTypes, language, t]
|
||||
);
|
||||
|
||||
const handleFromApiGraph = useCallback(
|
||||
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
|
||||
applyGraphWithSync(graph, wfInvocations);
|
||||
},
|
||||
[applyGraphWithSync]
|
||||
);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||
if (graph.nodes.length === 0) {
|
||||
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
|
||||
return;
|
||||
}
|
||||
setExecuting(true);
|
||||
setExecuteResult(null);
|
||||
try {
|
||||
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
|
||||
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
|
||||
...(ep ? { entryPointId: ep } : {}),
|
||||
});
|
||||
setExecuteResult(result);
|
||||
if (result.runId) {
|
||||
setTracingRunId(result.runId);
|
||||
setRightTab('tracing');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||
if (graph.nodes.length === 0) {
|
||||
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (currentWorkflowId) {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
} else {
|
||||
const label = await promptInput(t('Workflow-Name:'), {
|
||||
title: t('Workflow speichern'),
|
||||
defaultValue: t('Neuer Workflow'),
|
||||
placeholder: t('Name des Workflows'),
|
||||
});
|
||||
if (!label) {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const created = await createWorkflow(request, instanceId, {
|
||||
label: label.trim() || t('Neuer Workflow'),
|
||||
graph,
|
||||
invocations,
|
||||
});
|
||||
setCurrentWorkflowId(created.id);
|
||||
if (created.invocations?.length) setInvocations(created.invocations);
|
||||
setWorkflows((prev) => [...prev, created]);
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (workflowId: string) => {
|
||||
try {
|
||||
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
||||
if (wf.graph) {
|
||||
handleFromApiGraph(wf.graph, wf.invocations);
|
||||
} else {
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
|
||||
);
|
||||
|
||||
const handleWorkflowSelect = useCallback(
|
||||
(workflowId: string | null) => {
|
||||
setCurrentWorkflowId(workflowId);
|
||||
if (workflowId) handleLoad(workflowId);
|
||||
else {
|
||||
setExecuteResult(null);
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
||||
}
|
||||
},
|
||||
[handleLoad, applyGraphWithSync, t]
|
||||
);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
setCurrentWorkflowId(null);
|
||||
setExecuteResult(null);
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
||||
}, [applyGraphWithSync, t]);
|
||||
|
||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
const next = { ...n, parameters };
|
||||
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
||||
const cases = (parameters.cases as unknown[]) ?? [];
|
||||
next.outputs = Math.max(1, cases.length);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
const merged = { ...(n.parameters ?? {}), ...patch };
|
||||
const next = { ...n, parameters: merged };
|
||||
if (n.type === 'flow.switch' && 'cases' in merged) {
|
||||
const cases = (merged.cases as unknown[]) ?? [];
|
||||
next.outputs = Math.max(1, cases.length);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleNodeUpdate = useCallback(
|
||||
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleApplyWorkflowConfiguration = useCallback(
|
||||
(next: WorkflowEntryPoint[]) => {
|
||||
setInvocations(next);
|
||||
setCanvasNodes((nodes) => {
|
||||
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
|
||||
setCanvasConnections(r.connections);
|
||||
return r.nodes;
|
||||
});
|
||||
},
|
||||
[canvasConnections, nodeTypes, language]
|
||||
);
|
||||
|
||||
const loadNodeTypes = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchNodeTypes(request, instanceId, language);
|
||||
setNodeTypes(data.nodeTypes);
|
||||
setCategories(data.categories);
|
||||
if (data.portTypeCatalog) {
|
||||
setPortTypeCatalog(data.portTypeCatalog);
|
||||
setRegistryCatalog(data.portTypeCatalog as never);
|
||||
}
|
||||
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setNodeTypes([]);
|
||||
setCategories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, language, request]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const result = await fetchWorkflows(request, instanceId);
|
||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||
} catch (e) {
|
||||
console.error(`${LOG} loadWorkflows failed`, e);
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodeTypes();
|
||||
}, [loadNodeTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkflows();
|
||||
}, [loadWorkflows]);
|
||||
|
||||
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
|
||||
if (lastAppliedInitialRef.current === initialWorkflowId) return;
|
||||
lastAppliedInitialRef.current = initialWorkflowId;
|
||||
handleWorkflowSelect(initialWorkflowId);
|
||||
}, [initialWorkflowId, workflows, handleWorkflowSelect, nodeTypes.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || nodeTypes.length === 0) return;
|
||||
if (currentWorkflowId || initialWorkflowId) return;
|
||||
if (canvasNodes.length > 0) return;
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
||||
}, [
|
||||
loading,
|
||||
nodeTypes.length,
|
||||
currentWorkflowId,
|
||||
initialWorkflowId,
|
||||
canvasNodes.length,
|
||||
applyGraphWithSync,
|
||||
t,
|
||||
]);
|
||||
|
||||
const toggleCategory = useCallback((id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDropNodeType = useCallback(
|
||||
(nodeTypeId: string, x: number, y: number) => {
|
||||
if (nodeTypeId.startsWith('trigger.')) return;
|
||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||
if (!nt) return;
|
||||
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const label =
|
||||
typeof nt.label === 'string' ? nt.label : (nt.label as Record<string, string>)?.[language] ?? nt.id;
|
||||
setCanvasNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: nodeTypeId,
|
||||
x,
|
||||
y,
|
||||
label,
|
||||
title: label,
|
||||
color: nt.meta?.color,
|
||||
inputs: nt.inputs ?? 1,
|
||||
outputs: nt.outputs ?? 1,
|
||||
parameters: {},
|
||||
},
|
||||
]);
|
||||
},
|
||||
[nodeTypes, language]
|
||||
);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!instanceId || !currentWorkflowId) {
|
||||
setVersions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const v = await fetchVersions(request, instanceId, currentWorkflowId);
|
||||
setVersions(v);
|
||||
} catch (e) {
|
||||
console.error(`${LOG} loadVersions failed`, e);
|
||||
}
|
||||
}, [instanceId, currentWorkflowId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
loadVersions();
|
||||
}, [loadVersions]);
|
||||
|
||||
const handleVersionSelect = useCallback(
|
||||
(versionId: string | null) => {
|
||||
setCurrentVersionId(versionId);
|
||||
if (versionId) {
|
||||
const v = versions.find((ver) => ver.id === versionId);
|
||||
if (v?.graph) {
|
||||
handleFromApiGraph(v.graph, v.invocations);
|
||||
}
|
||||
}
|
||||
},
|
||||
[versions, handleFromApiGraph]
|
||||
);
|
||||
|
||||
const handlePublishVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
if (!instanceId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
await publishVersion(request, instanceId, versionId);
|
||||
await loadVersions();
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, loadVersions]
|
||||
);
|
||||
|
||||
const handleUnpublishVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
if (!instanceId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
await unpublishVersion(request, instanceId, versionId);
|
||||
await loadVersions();
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, loadVersions]
|
||||
);
|
||||
|
||||
const handleArchiveVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
if (!instanceId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
await archiveVersion(request, instanceId, versionId);
|
||||
await loadVersions();
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, loadVersions]
|
||||
);
|
||||
|
||||
const handleCreateDraft = useCallback(async () => {
|
||||
if (!instanceId || !currentWorkflowId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
|
||||
await loadVersions();
|
||||
setCurrentVersionId(draft.id);
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
}, [request, instanceId, currentWorkflowId, loadVersions]);
|
||||
|
||||
// Template: save current workflow as template
|
||||
const [templateSaving, setTemplateSaving] = useState(false);
|
||||
const handleSaveAsTemplate = useCallback(
|
||||
async (scope: AutoTemplateScope) => {
|
||||
if (!instanceId || !currentWorkflowId) return;
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
|
||||
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setTemplateSaving(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, currentWorkflowId]
|
||||
);
|
||||
|
||||
// Template: new workflow from template
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||
const handleNewFromTemplate = useCallback(
|
||||
async (templateId: string) => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const wf = await copyTemplate(request, instanceId, templateId);
|
||||
setWorkflows((prev) => [...prev, wf]);
|
||||
setCurrentWorkflowId(wf.id);
|
||||
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
|
||||
setTemplatePickerOpen(false);
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
[request, instanceId, handleFromApiGraph]
|
||||
);
|
||||
|
||||
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
|
||||
try {
|
||||
await updateWorkflow(request, instanceId, workflowId, { label: newName });
|
||||
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
|
||||
} catch (e: unknown) {
|
||||
console.error(`${LOG} rename failed`, e);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
||||
|
||||
const renderSidebar = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.sidebar} style={_sidebarStyle}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
|
||||
</div>
|
||||
<div className={styles.loading}>
|
||||
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
|
||||
<p>{t('Lade Nodetypen…')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.sidebar} style={_sidebarStyle}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
|
||||
</div>
|
||||
<div className={styles.error}>
|
||||
<p>{error}</p>
|
||||
<button className={styles.retryButton} onClick={loadNodeTypes}>
|
||||
{t('Erneut versuchen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NodeSidebar
|
||||
nodeTypes={nodeTypes}
|
||||
categories={categories}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
language={language}
|
||||
expandedCategories={expandedCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
excludedCategories={sidebarExcludedCategories}
|
||||
style={_sidebarStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const configurableSelected =
|
||||
selectedNode &&
|
||||
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
|
||||
selectedNode.type.startsWith(p)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
||||
{leftPanelOpen && (<>
|
||||
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
|
||||
<div className={styles.rightTabBar}>
|
||||
{(['chats', 'files', 'sources'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
|
||||
onClick={() => setUdbTab(tab)}
|
||||
>
|
||||
{{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{udbTab === 'chats' ? (
|
||||
<EditorChatPanel
|
||||
instanceId={instanceId}
|
||||
workflowId={currentWorkflowId}
|
||||
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
|
||||
pendingFiles={pendingFiles}
|
||||
onRemovePendingFile={onRemovePendingFile}
|
||||
dataSources={dataSources}
|
||||
featureDataSources={featureDataSources}
|
||||
/>
|
||||
) : (
|
||||
<UnifiedDataBar
|
||||
context={udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={onFileSelect}
|
||||
onSourcesChanged={onSourcesChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('left', e)} />
|
||||
</>)}
|
||||
|
||||
{/* Canvas area - center */}
|
||||
<div className={styles.canvas}>
|
||||
<CanvasHeader
|
||||
workflows={workflows}
|
||||
currentWorkflowId={currentWorkflowId}
|
||||
onWorkflowSelect={handleWorkflowSelect}
|
||||
onNew={handleNew}
|
||||
onSave={handleSave}
|
||||
onExecute={handleExecute}
|
||||
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
||||
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
|
||||
saving={saving}
|
||||
executing={executing}
|
||||
hasNodes={canvasNodes.length > 0}
|
||||
executeResult={executeResult}
|
||||
versions={versions}
|
||||
currentVersionId={currentVersionId}
|
||||
onVersionSelect={handleVersionSelect}
|
||||
onPublishVersion={handlePublishVersion}
|
||||
onUnpublishVersion={handleUnpublishVersion}
|
||||
onArchiveVersion={handleArchiveVersion}
|
||||
onCreateDraft={handleCreateDraft}
|
||||
versionLoading={versionLoading}
|
||||
onSaveAsTemplate={handleSaveAsTemplate}
|
||||
templateSaving={templateSaving}
|
||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||
onWorkflowRename={handleWorkflowRename}
|
||||
/>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<FlowCanvas
|
||||
nodes={canvasNodes}
|
||||
connections={canvasConnections}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={setCanvasNodes}
|
||||
onConnectionsChange={setCanvasConnections}
|
||||
onDropNodeType={handleDropNodeType}
|
||||
getLabel={(node) => node.title ?? node.label ?? node.type}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
onSelectionChange={setSelectedNode}
|
||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||
/>
|
||||
</div>
|
||||
{configurableSelected && selectedNode && (
|
||||
<Automation2DataFlowProvider
|
||||
node={selectedNode}
|
||||
nodes={canvasNodes}
|
||||
connections={canvasConnections}
|
||||
nodeOutputsPreview={nodeOutputsPreview}
|
||||
nodeTypes={nodeTypes}
|
||||
language={language}
|
||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||
systemVariables={systemVariables as Record<string, never>}
|
||||
>
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
||||
language={language}
|
||||
onParametersChange={handleNodeParametersChange}
|
||||
onMergeNodeParameters={handleMergeNodeParameters}
|
||||
onNodeUpdate={handleNodeUpdate}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
/>
|
||||
</Automation2DataFlowProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Nodes + Tracing tabs */}
|
||||
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
|
||||
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
|
||||
<div className={styles.rightTabBar}>
|
||||
<button
|
||||
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
|
||||
onClick={() => setRightTab('nodes')}
|
||||
>
|
||||
{t('Knoten')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.rightTab} ${rightTab === 'tracing' ? styles.rightTabActive : ''}`}
|
||||
onClick={() => { setRightTab('tracing'); if (!tracingRunId) setTracingRunId('select'); }}
|
||||
>
|
||||
{t('Ablaufverfolgung')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightTab === 'nodes' ? (
|
||||
renderSidebar()
|
||||
) : (
|
||||
<RunTracingPanel
|
||||
instanceId={instanceId}
|
||||
runId={tracingRunId === 'select' ? null : tracingRunId}
|
||||
onNodeSelect={(nodeId) => {
|
||||
const node = canvasNodes.find((n) => n.id === nodeId);
|
||||
if (node) setSelectedNode(node);
|
||||
}}
|
||||
onActiveStepsChange={setTracingNodeStatuses}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptDialog />
|
||||
<WorkflowConfigurationModal
|
||||
open={workflowSettingsOpen}
|
||||
onClose={() => setWorkflowSettingsOpen(false)}
|
||||
invocations={invocations}
|
||||
onApply={handleApplyWorkflowConfiguration}
|
||||
/>
|
||||
<TemplatePicker
|
||||
open={templatePickerOpen}
|
||||
onClose={() => setTemplatePickerOpen(false)}
|
||||
onSelect={handleNewFromTemplate}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Automation2FlowEditor;
|
||||
405
src/components/FlowEditor/editor/CanvasHeader.tsx
Normal file
405
src/components/FlowEditor/editor/CanvasHeader.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
/**
|
||||
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown } from 'react-icons/fa';
|
||||
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
workflows: Automation2Workflow[];
|
||||
currentWorkflowId: string | null;
|
||||
onWorkflowSelect: (workflowId: string | null) => void;
|
||||
onNew: () => void;
|
||||
onSave: () => void;
|
||||
onExecute: () => void;
|
||||
onWorkflowSettings?: () => void;
|
||||
onToggleChat?: () => void;
|
||||
saving: boolean;
|
||||
executing: boolean;
|
||||
hasNodes: boolean;
|
||||
executeResult: ExecuteGraphResponse | null;
|
||||
versions?: AutoVersion[];
|
||||
currentVersionId?: string | null;
|
||||
onVersionSelect?: (versionId: string | null) => void;
|
||||
onPublishVersion?: (versionId: string) => void;
|
||||
onUnpublishVersion?: (versionId: string) => void;
|
||||
onArchiveVersion?: (versionId: string) => void;
|
||||
onCreateDraft?: () => void;
|
||||
versionLoading?: boolean;
|
||||
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
||||
templateSaving?: boolean;
|
||||
onNewFromTemplate?: () => void;
|
||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
||||
}
|
||||
|
||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||
return {
|
||||
draft: { label: t('Entwurf'), color: 'var(--warning-color, #ffc107)' },
|
||||
published: { label: t('Veröffentlicht'), color: 'var(--success-color, #28a745)' },
|
||||
archived: { label: t('Archiviert'), color: 'var(--text-secondary, #666)' },
|
||||
};
|
||||
}
|
||||
|
||||
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||
currentWorkflowId,
|
||||
onWorkflowSelect,
|
||||
onNew,
|
||||
onSave,
|
||||
onExecute,
|
||||
onWorkflowSettings,
|
||||
onToggleChat,
|
||||
saving,
|
||||
executing,
|
||||
hasNodes,
|
||||
executeResult,
|
||||
versions,
|
||||
currentVersionId,
|
||||
onVersionSelect,
|
||||
onPublishVersion,
|
||||
onUnpublishVersion,
|
||||
onArchiveVersion,
|
||||
onCreateDraft,
|
||||
versionLoading,
|
||||
onSaveAsTemplate,
|
||||
templateSaving,
|
||||
onNewFromTemplate,
|
||||
onWorkflowRename,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const statusBadge = _getStatusBadge(t);
|
||||
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
||||
const currentStatus = currentVersion?.status || 'draft';
|
||||
const badge = statusBadge[currentStatus] || statusBadge.draft;
|
||||
|
||||
const [newMenuOpen, setNewMenuOpen] = useState(false);
|
||||
const newMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState('');
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
|
||||
|
||||
const _startNameEdit = useCallback(() => {
|
||||
if (!currentWorkflowId || !onWorkflowRename) return;
|
||||
setNameValue(currentWorkflow?.label || '');
|
||||
setEditingName(true);
|
||||
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
|
||||
|
||||
const _commitNameEdit = useCallback(() => {
|
||||
setEditingName(false);
|
||||
const trimmed = nameValue.trim();
|
||||
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
|
||||
if (trimmed !== currentWorkflow?.label) {
|
||||
onWorkflowRename(currentWorkflowId, trimmed);
|
||||
}
|
||||
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingName && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
nameInputRef.current.select();
|
||||
}
|
||||
}, [editingName]);
|
||||
|
||||
useEffect(() => {
|
||||
const _handleClickOutside = (e: MouseEvent) => {
|
||||
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
||||
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', _handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const scopeLabels = useMemo(
|
||||
() =>
|
||||
({
|
||||
user: t('Meine Vorlagen'),
|
||||
instance: t('Instanz'),
|
||||
mandate: t('Mandant'),
|
||||
}) as Record<string, string>,
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.canvasHeader}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{/* Workflow name: inline editable */}
|
||||
{currentWorkflowId && currentWorkflow ? (
|
||||
editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onBlur={_commitNameEdit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }}
|
||||
/>
|
||||
) : (
|
||||
<h4
|
||||
className={styles.canvasTitle}
|
||||
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
|
||||
onClick={_startNameEdit}
|
||||
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
|
||||
>
|
||||
{currentWorkflow.label}
|
||||
</h4>
|
||||
)
|
||||
) : (
|
||||
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
|
||||
{t('Neuer Workflow')}
|
||||
</h4>
|
||||
)}
|
||||
{onWorkflowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.canvasGearBtn}
|
||||
title={t('Workflowkonfiguration Einstieg/Starts')}
|
||||
aria-label={t('Workflow-Konfiguration')}
|
||||
onClick={onWorkflowSettings}
|
||||
>
|
||||
<FaCog />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Split "Neu" button */}
|
||||
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
|
||||
{t('Neu')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setNewMenuOpen((p) => !p)}
|
||||
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
|
||||
title={t('Neu aus Vorlage')}
|
||||
>
|
||||
<FaCaretDown style={{ fontSize: '0.7rem' }} />
|
||||
</button>
|
||||
</div>
|
||||
{newMenuOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onNew(); setNewMenuOpen(false); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
{t('Leerer Workflow')}
|
||||
</button>
|
||||
{onNewFromTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
|
||||
>
|
||||
{t('Aus Vorlage…')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onSave}
|
||||
disabled={saving || !hasNodes}
|
||||
>
|
||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
||||
</button>
|
||||
|
||||
{/* Save as template */}
|
||||
{currentWorkflowId && onSaveAsTemplate && (
|
||||
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||
disabled={templateSaving}
|
||||
title={t('Als Vorlage speichern')}
|
||||
>
|
||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
||||
</button>
|
||||
{templateMenuOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
|
||||
>
|
||||
{scopeLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
value={currentWorkflowId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? e.target.value : null;
|
||||
onWorkflowSelect(id);
|
||||
}}
|
||||
style={{ padding: '0.4rem', minWidth: 180 }}
|
||||
>
|
||||
<option value="">{t('Workflow laden')}</option>
|
||||
{workflows.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onExecute}
|
||||
disabled={executing || !hasNodes}
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
||||
{t('Ausführen…')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaPlay style={{ marginRight: '0.5rem' }} />
|
||||
{t('Ausführen')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{onToggleChat && (
|
||||
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
|
||||
<FaDatabase style={{ marginRight: '0.4rem' }} />
|
||||
{t('Workspace')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version Selector */}
|
||||
{currentWorkflowId && versions && versions.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>{t('Version:')}</span>
|
||||
<select
|
||||
value={currentVersionId ?? ''}
|
||||
onChange={(e) => onVersionSelect?.(e.target.value || null)}
|
||||
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
|
||||
disabled={versionLoading}
|
||||
>
|
||||
<option value="">{t('Aktuelle')}</option>
|
||||
{versions.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} ({statusBadge[v.status]?.label ?? v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
background: badge.color + '22',
|
||||
color: badge.color,
|
||||
}}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => onPublishVersion(currentVersion.id)}
|
||||
disabled={versionLoading}
|
||||
title={t('Version veröffentlichen')}
|
||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
||||
>
|
||||
<FaCloudUploadAlt style={{ marginRight: 4 }} />
|
||||
{t('Veröffentlichen')}
|
||||
</button>
|
||||
)}
|
||||
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => onUnpublishVersion(currentVersion.id)}
|
||||
disabled={versionLoading}
|
||||
title={t('Veröffentlichung zurücknehmen')}
|
||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
||||
>
|
||||
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
|
||||
{t('Veröffentlichung aufheben')}
|
||||
</button>
|
||||
)}
|
||||
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => onArchiveVersion(currentVersion.id)}
|
||||
disabled={versionLoading}
|
||||
title={t('Version archivieren')}
|
||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
||||
>
|
||||
<FaArchive style={{ marginRight: 4 }} />
|
||||
Archiv
|
||||
</button>
|
||||
)}
|
||||
{onCreateDraft && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onCreateDraft}
|
||||
disabled={versionLoading}
|
||||
title={t('Neuen Entwurf erstellen')}
|
||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
||||
>
|
||||
+ Entwurf
|
||||
</button>
|
||||
)}
|
||||
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{executeResult && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.875rem',
|
||||
background: executeResult.success
|
||||
? 'rgba(40,167,69,0.15)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'rgba(0,123,255,0.15)'
|
||||
: 'rgba(220,53,69,0.15)',
|
||||
color: executeResult.success
|
||||
? 'var(--success-color,#28a745)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'var(--primary-color,#007bff)'
|
||||
: 'var(--danger-color,#dc3545)',
|
||||
}}
|
||||
>
|
||||
{executeResult.success ? (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
) : (executeResult as { paused?: boolean }).paused ? (
|
||||
<>
|
||||
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
||||
Task zu bearbeiten.
|
||||
</>
|
||||
) : (
|
||||
<>✗ {executeResult.error ?? t('Unbekannter Fehler')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
409
src/components/FlowEditor/editor/EditorChatPanel.tsx
Normal file
409
src/components/FlowEditor/editor/EditorChatPanel.tsx
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
/**
|
||||
* EditorChatPanel
|
||||
*
|
||||
* AI Chat sidebar for the GraphicalEditor.
|
||||
* Streams responses via SSE (same pattern as Workspace chat).
|
||||
* File & data-source attachment UX mirrors WorkspaceInput:
|
||||
* - Files: drag & drop from FolderTree onto input area, or click in UDB
|
||||
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
||||
*/
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { startSseStream } from '../../../utils/sseClient';
|
||||
import { ChatMessageList } from '../../Chat';
|
||||
import type { ChatMessage } from '../../Chat';
|
||||
import { getPageIcon } from '../../../config/pageRegistry';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
export interface PendingFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
itemType?: 'file' | 'folder';
|
||||
}
|
||||
|
||||
export interface EditorDataSource {
|
||||
id: string;
|
||||
label: string;
|
||||
path?: string;
|
||||
sourceType?: string;
|
||||
}
|
||||
|
||||
export interface EditorFeatureDataSource {
|
||||
id: string;
|
||||
featureInstanceId: string;
|
||||
featureCode: string;
|
||||
tableName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EditorChatPanelProps {
|
||||
instanceId: string;
|
||||
workflowId: string | null;
|
||||
onGraphUpdated?: () => void;
|
||||
pendingFiles?: PendingFile[];
|
||||
onRemovePendingFile?: (fileId: string) => void;
|
||||
dataSources?: EditorDataSource[];
|
||||
featureDataSources?: EditorFeatureDataSource[];
|
||||
}
|
||||
|
||||
let _msgCounter = 0;
|
||||
|
||||
export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||
workflowId,
|
||||
onGraphUpdated,
|
||||
pendingFiles = [],
|
||||
onRemovePendingFile,
|
||||
dataSources = [],
|
||||
featureDataSources = [],
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||
const abortRef = useRef<(() => void) | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const _toggleDataSource = useCallback((dsId: string) => {
|
||||
setAttachedDataSourceIds(prev =>
|
||||
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
|
||||
setAttachedFeatureDataSourceIds(prev =>
|
||||
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const _handleSend = useCallback(() => {
|
||||
const trimmed = prompt.trim();
|
||||
if (!workflowId || loading || !trimmed) return;
|
||||
|
||||
const fileIds = pendingFiles.map(f => f.fileId);
|
||||
const body: Record<string, unknown> = {
|
||||
message: trimmed,
|
||||
conversationHistory: messages.map(m => ({ role: m.role, message: m.content })),
|
||||
userLanguage: navigator.language?.slice(0, 2) || 'de',
|
||||
};
|
||||
if (fileIds.length > 0) body.fileIds = fileIds;
|
||||
if (attachedDataSourceIds.length > 0) body.dataSourceIds = attachedDataSourceIds;
|
||||
if (attachedFeatureDataSourceIds.length > 0) body.featureDataSourceIds = attachedFeatureDataSourceIds;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: `user-${++_msgCounter}`,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setPrompt('');
|
||||
setShowSourcePicker(false);
|
||||
setLoading(true);
|
||||
|
||||
const assistantId = `asst-${++_msgCounter}`;
|
||||
let accumulated = '';
|
||||
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
|
||||
|
||||
const cleanup = startSseStream({
|
||||
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
||||
body,
|
||||
handlers: {
|
||||
onChunk: (event) => {
|
||||
if (event.content) {
|
||||
accumulated += event.content;
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
|
||||
}
|
||||
},
|
||||
onRawEvent: (event) => {
|
||||
if (event.type === 'message' && event.content) {
|
||||
accumulated += event.content;
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
|
||||
}
|
||||
if (event.type === 'toolResult' || event.type === 'toolCall') {
|
||||
onGraphUpdated?.();
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
if (!accumulated) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: t('Fertig.') } : m));
|
||||
}
|
||||
onGraphUpdated?.();
|
||||
setLoading(false);
|
||||
},
|
||||
onError: (event) => {
|
||||
const errText = event.content || t('Anfrage fehlgeschlagen');
|
||||
if (!accumulated) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${errText}` } : m));
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
onStopped: () => setLoading(false),
|
||||
},
|
||||
onConnectionError: (err) => {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
|
||||
setLoading(false);
|
||||
},
|
||||
onStreamEnd: () => setLoading(false),
|
||||
});
|
||||
|
||||
abortRef.current = cleanup;
|
||||
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
|
||||
|
||||
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
_handleSend();
|
||||
}
|
||||
}, [_handleSend]);
|
||||
|
||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes('application/tree-items')) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setTreeDropOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const _handleDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||||
|
||||
const _handleDrop = useCallback((e: React.DragEvent) => {
|
||||
setTreeDropOver(false);
|
||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||
if (treeItemsJson) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasAttachments = pendingFiles.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
|
||||
const sourceCount = attachedDataSourceIds.length + attachedFeatureDataSourceIds.length;
|
||||
const hasSourceOptions = dataSources.length > 0 || featureDataSources.length > 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isProcessing={loading}
|
||||
emptyMessage={t('Beschreiben Sie, was Sie tun möchten')}
|
||||
/>
|
||||
|
||||
{/* Pending files (from UDB drag/click) */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div style={{
|
||||
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
|
||||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||
background: 'var(--bg-secondary, #fafafa)',
|
||||
}}>
|
||||
{pendingFiles.map(pf => (
|
||||
<span key={pf.fileId} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
|
||||
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
|
||||
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
|
||||
}}>
|
||||
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
||||
{onRemovePendingFile && (
|
||||
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
|
||||
}}>x</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached data sources chips */}
|
||||
{(attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0) && (
|
||||
<div style={{
|
||||
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
|
||||
borderTop: pendingFiles.length > 0 ? 'none' : '1px solid var(--border-color, #e0e0e0)',
|
||||
background: '#fafafa',
|
||||
}}>
|
||||
{attachedDataSourceIds.map(dsId => {
|
||||
const ds = dataSources.find(d => d.id === dsId);
|
||||
return (
|
||||
<span key={dsId} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
|
||||
}}>
|
||||
\uD83D\uDD17 {ds?.label || dsId}
|
||||
<button onClick={() => _toggleDataSource(dsId)} style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1,
|
||||
}}>x</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{attachedFeatureDataSourceIds.map(fdsId => {
|
||||
const fds = featureDataSources.find(d => d.id === fdsId);
|
||||
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
|
||||
return (
|
||||
<span key={fdsId} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', fontSize: 11 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
|
||||
{fds?.label || fdsId}
|
||||
<button onClick={() => _toggleFeatureDataSource(fdsId)} style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1,
|
||||
}}>x</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: hasAttachments ? 'none' : '1px solid var(--border-color, #e0e0e0)',
|
||||
padding: '8px 12px',
|
||||
display: 'flex', gap: 6, alignItems: 'flex-end',
|
||||
outline: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : 'none',
|
||||
background: treeDropOver ? 'rgba(242, 88, 67, 0.08)' : undefined,
|
||||
transition: 'background 0.15s, outline 0.15s',
|
||||
}}
|
||||
onDragOver={_handleDragOver}
|
||||
onDragLeave={_handleDragLeave}
|
||||
onDrop={_handleDrop}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
onKeyDown={_handleKeyDown}
|
||||
placeholder={workflowId ? t('Beschreiben Sie eine Änderung') : t('Speichern Sie zuerst den Workflow')}
|
||||
disabled={!workflowId || loading}
|
||||
style={{
|
||||
flex: 1, minHeight: 36, maxHeight: 100, resize: 'vertical',
|
||||
padding: '8px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-color, #ccc)',
|
||||
fontSize: 13, fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Source picker button */}
|
||||
{hasSourceOptions && (
|
||||
<div style={{ position: 'relative' }} ref={pickerRef}>
|
||||
<button
|
||||
onClick={() => setShowSourcePicker(prev => !prev)}
|
||||
disabled={loading || !workflowId}
|
||||
title={t('Datenquellen anhängen')}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
background: sourceCount > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
|
||||
color: sourceCount > 0 ? '#2e7d32' : '#666',
|
||||
cursor: loading || !workflowId ? 'not-allowed' : 'pointer',
|
||||
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
opacity: loading ? 0.5 : 1, position: 'relative',
|
||||
}}
|
||||
>
|
||||
{'\uD83D\uDD17'}
|
||||
{sourceCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -4, right: -4,
|
||||
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
|
||||
borderRadius: '50%', width: 16, height: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>{sourceCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{showSourcePicker && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
||||
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
|
||||
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||||
minWidth: 220, maxHeight: 260, overflowY: 'auto',
|
||||
}}>
|
||||
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
|
||||
{t('Aktive Quellen auswählen')}
|
||||
</div>
|
||||
{dataSources.map(ds => {
|
||||
const isSelected = attachedDataSourceIds.includes(ds.id);
|
||||
return (
|
||||
<div key={ds.id} onClick={() => _toggleDataSource(ds.id)} style={{
|
||||
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: isSelected ? '#e8f5e9' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#e8f5e9' : ''; }}
|
||||
>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 3,
|
||||
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
|
||||
background: isSelected ? '#2e7d32' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
|
||||
}}>{isSelected ? '\u2713' : ''}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{ds.label || ds.path || ds.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{featureDataSources.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
|
||||
{t('Feature-Datenquellen')}
|
||||
</div>
|
||||
{featureDataSources.map(fds => {
|
||||
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
|
||||
return (
|
||||
<div key={fds.id} onClick={() => _toggleFeatureDataSource(fds.id)} style={{
|
||||
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: isSelected ? '#f3e5f5' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#f3e5f5' : ''; }}
|
||||
>
|
||||
<span style={{
|
||||
width: 14, height: 14, borderRadius: 3,
|
||||
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
|
||||
background: isSelected ? '#7b1fa2' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
|
||||
}}>{isSelected ? '\u2713' : ''}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>
|
||||
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{fds.label || fds.featureCode} – {fds.tableName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<button onClick={() => abortRef.current?.()} style={{
|
||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
|
||||
}}>{t('Stopp')}</button>
|
||||
) : (
|
||||
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
|
||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||
background: prompt.trim() && workflowId ? 'var(--primary-color, #F25843)' : '#ccc',
|
||||
color: '#fff', cursor: prompt.trim() && workflowId ? 'pointer' : 'default',
|
||||
fontWeight: 600, fontSize: 12,
|
||||
}}>{t('Senden')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,9 +4,11 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { NodeType } from '../../../api/automation2Api';
|
||||
import type { NodeType } from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string;
|
||||
type: string;
|
||||
|
|
@ -19,6 +21,8 @@ export interface CanvasNode {
|
|||
inputs: number;
|
||||
outputs: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||
outputPorts?: Array<{ name: string; schema: string }>;
|
||||
}
|
||||
|
||||
export interface CanvasConnection {
|
||||
|
|
@ -34,6 +38,30 @@ const NODE_HEIGHT = 72;
|
|||
const HANDLE_SIZE = 12;
|
||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
||||
|
||||
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
||||
function _checkConnectionCompatibility(
|
||||
sourceNode: CanvasNode,
|
||||
sourceOutputIdx: number,
|
||||
targetNode: CanvasNode,
|
||||
targetInputIdx: number,
|
||||
nodeTypes: NodeType[],
|
||||
): 'ok' | 'warning' {
|
||||
const srcType = nodeTypes.find((nt) => nt.id === sourceNode.type);
|
||||
const tgtType = nodeTypes.find((nt) => nt.id === targetNode.type);
|
||||
if (!srcType?.outputPorts || !tgtType?.inputPorts) return 'ok';
|
||||
|
||||
const srcPort = srcType.outputPorts[sourceOutputIdx];
|
||||
const tgtPort = tgtType.inputPorts[targetInputIdx];
|
||||
if (!srcPort || !tgtPort) return 'ok';
|
||||
|
||||
const srcSchema = srcPort.schema;
|
||||
const accepts = tgtPort.accepts;
|
||||
if (!accepts || accepts.length === 0) return 'ok';
|
||||
if (accepts.includes('Transit')) return 'ok';
|
||||
if (accepts.includes(srcSchema)) return 'ok';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
interface FlowCanvasProps {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
|
|
@ -44,10 +72,17 @@ interface FlowCanvasProps {
|
|||
getLabel: (node: CanvasNode) => string;
|
||||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||
highlightedNodeIds?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||
nodes,
|
||||
const HIGHLIGHT_COLORS: Record<string, string> = {
|
||||
running: '#f0ad4e',
|
||||
completed: '#28a745',
|
||||
failed: '#dc3545',
|
||||
skipped: '#6c757d',
|
||||
};
|
||||
|
||||
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||
connections,
|
||||
nodeTypes,
|
||||
onNodesChange,
|
||||
|
|
@ -56,11 +91,14 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
getLabel,
|
||||
getCategoryIcon,
|
||||
onSelectionChange,
|
||||
highlightedNodeIds,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
||||
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
|
||||
const [connectionWarnings, setConnectionWarnings] = useState<Record<string, boolean>>({});
|
||||
const [selectionBox, setSelectionBox] = useState<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
|
|
@ -236,6 +274,18 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
targetId: targetNodeId,
|
||||
targetHandle: targetHandleIndex,
|
||||
};
|
||||
|
||||
const srcNode = nodes.find((n) => n.id === connectingFrom.nodeId);
|
||||
const tgtNode = nodes.find((n) => n.id === targetNodeId);
|
||||
if (srcNode && tgtNode) {
|
||||
const sourceOutputIdx = connectingFrom.handleIndex >= srcNode.inputs
|
||||
? connectingFrom.handleIndex - srcNode.inputs : 0;
|
||||
const compat = _checkConnectionCompatibility(srcNode, sourceOutputIdx, tgtNode, targetHandleIndex, nodeTypes);
|
||||
if (compat === 'warning') {
|
||||
setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true }));
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionsChange([...connections, newConn]);
|
||||
setConnectingFrom(null);
|
||||
setDragPos(null);
|
||||
|
|
@ -510,17 +560,30 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
>
|
||||
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
|
||||
<div className={styles.connectionHint}>
|
||||
{selectedNodeIds.size} Nodes ausgewählt • <kbd>Entf</kbd> zum Löschen • Ziehen zum Verschieben • <kbd>Shift</kbd>+Klick zum Hinzufügen/Entfernen
|
||||
{selectedNodeIds.size} {t('Knoten ausgewählt')}
|
||||
{' · '}
|
||||
<kbd>Entf</kbd> {t('zum Löschen')}
|
||||
{' · '}
|
||||
{t('Ziehen zum Verschieben')}
|
||||
{' · '}
|
||||
<kbd>Shift</kbd>
|
||||
{t('+Klick zum Hinzufügen oder Entfernen')}
|
||||
</div>
|
||||
)}
|
||||
{connectingFrom && !selectedConnectionId && (
|
||||
<div className={styles.connectionHint}>
|
||||
Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang • <kbd>Esc</kbd> zum Abbrechen
|
||||
{t('Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang')}
|
||||
{' · '}
|
||||
<kbd>Esc</kbd> {t('zum Abbrechen')}
|
||||
</div>
|
||||
)}
|
||||
{selectedConnectionId && (
|
||||
<div className={styles.connectionHint}>
|
||||
Pfeil ausgewählt • <kbd>Entf</kbd> zum Löschen • Klicken Sie auf einen anderen Eingang zum Umleiten
|
||||
{t('Verbindungspfeil ausgewählt')}
|
||||
{' · '}
|
||||
<kbd>Entf</kbd> {t('zum Löschen')}
|
||||
{' · '}
|
||||
{t('Anderen Eingang anklicken zum Umleiten')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -559,6 +622,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowhead-warning"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#FF9800" />
|
||||
</marker>
|
||||
</defs>
|
||||
{connections.map((c) => {
|
||||
const srcNode = nodes.find((n) => n.id === c.sourceId);
|
||||
|
|
@ -569,6 +642,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
const dx = tgt.x - src.x;
|
||||
const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
|
||||
const isSelected = selectedConnectionId === c.id;
|
||||
const isWarning = connectionWarnings[c.id];
|
||||
const strokeColor = isSelected
|
||||
? 'var(--primary-color, #007bff)'
|
||||
: isWarning
|
||||
? '#FF9800'
|
||||
: 'var(--text-secondary, #666)';
|
||||
return (
|
||||
<g
|
||||
key={c.id}
|
||||
|
|
@ -576,7 +655,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
style={{ cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Verbindung auswählen (Entf zum Löschen, klicken Sie auf einen anderen Eingang zum Umleiten)"
|
||||
aria-label={t('Verbindung auswählen, Entf zum Löschen')}
|
||||
>
|
||||
<path
|
||||
d={pathD}
|
||||
|
|
@ -588,11 +667,15 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={isSelected ? 'var(--primary-color, #007bff)' : 'var(--text-secondary, #666)'}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
|
||||
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{isWarning && !isSelected && (
|
||||
<title>{t('Typeninkompatibilität: Ausgabetyp')}</title>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
|
@ -621,18 +704,21 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
const isSelected = selectedNodeIds.has(node.id);
|
||||
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||||
const hlStatus = highlightedNodeIds?.[node.id];
|
||||
const hlColor = hlStatus ? HIGHLIGHT_COLORS[hlStatus] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''}`}
|
||||
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''} ${hlStatus ? styles.canvasNodeHighlighted : ''}`}
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}15`,
|
||||
borderColor: hlColor || color,
|
||||
backgroundColor: hlColor ? `${hlColor}20` : `${color}15`,
|
||||
boxShadow: hlStatus === 'running' ? `0 0 12px ${hlColor}80` : undefined,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => {
|
||||
|
|
@ -687,8 +773,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
outputLabel ??
|
||||
(selectedConnectionId && !isOutput
|
||||
? used
|
||||
? 'Aktuelles Ziel – klicken zum Abwählen'
|
||||
: 'Klicken zum Umleiten'
|
||||
? t('Aktuelles Ziel klicken, um abzuwählen')
|
||||
: t('Klicken zum Umleiten')
|
||||
: undefined)
|
||||
}
|
||||
/>
|
||||
|
|
@ -743,7 +829,13 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
{displayTitle}
|
||||
</span>
|
||||
)}
|
||||
{node.comment && (
|
||||
<span className={styles.canvasNodeComment}>{node.comment}</span>
|
||||
)}
|
||||
</div>
|
||||
{node.comment && (
|
||||
<div className={styles.canvasNodeCommentTooltip}>{node.comment}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -761,7 +853,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
)}
|
||||
{nodes.length === 0 && (
|
||||
<div className={styles.canvasPlaceholder}>
|
||||
<p>Nodes aus der Liste links hierher ziehen.</p>
|
||||
<p>{t('Knoten aus der Liste links ziehen')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
/**
|
||||
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
|
||||
* Delegates to config components from nodes/configs.
|
||||
* NodeConfigPanel - Generic parameter renderer for all node types.
|
||||
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { CanvasNode } from './FlowCanvas';
|
||||
import type { NodeType } from '../../../api/automation2Api';
|
||||
import type { ApiRequestFunction } from '../../../api/automation2Api';
|
||||
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { NODE_CONFIG_REGISTRY } from '../nodes/configs';
|
||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface NodeConfigPanelProps {
|
||||
node: CanvasNode | null;
|
||||
nodeType: NodeType | undefined;
|
||||
|
|
@ -22,18 +24,16 @@ interface NodeConfigPanelProps {
|
|||
request?: ApiRequestFunction;
|
||||
}
|
||||
|
||||
const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.'];
|
||||
|
||||
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||
node,
|
||||
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||
nodeType,
|
||||
language,
|
||||
onParametersChange,
|
||||
onMergeNodeParameters,
|
||||
onMergeNodeParameters: _onMergeNodeParameters,
|
||||
onNodeUpdate,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const nodeIdRef = useRef<string | undefined>(undefined);
|
||||
nodeIdRef.current = node?.id;
|
||||
|
|
@ -52,7 +52,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
|||
};
|
||||
}, [node?.id]);
|
||||
|
||||
/** Do not call onParametersChange (parent setState) inside setParams updater — React forbids updating a parent during a child's state update. */
|
||||
const updateParam = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setParams((prev) => {
|
||||
|
|
@ -73,48 +72,51 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
|||
[onParametersChange]
|
||||
);
|
||||
|
||||
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
|
||||
if (!node || !isConfigurable) return null;
|
||||
|
||||
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
|
||||
if (!ConfigRenderer) {
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||
<p>No configuration for {node.type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!node || !nodeType) return null;
|
||||
|
||||
const isTrigger = node.type.startsWith('trigger.');
|
||||
const showNameField = onNodeUpdate && !isTrigger;
|
||||
const parameters = nodeType.parameters || [];
|
||||
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
{showNameField && (
|
||||
<div className={styles.nodeConfigNameRow}>
|
||||
<label htmlFor="node-config-name">Bezeichnung</label>
|
||||
<label htmlFor="node-config-name">{t('Bezeichnung')}</label>
|
||||
<input
|
||||
id="node-config-name"
|
||||
type="text"
|
||||
value={node.title ?? ''}
|
||||
onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })}
|
||||
placeholder="z.B. Kundenformular, Prüfen Land"
|
||||
placeholder={t('z.B. Kundenformular prüfen, Land')}
|
||||
/>
|
||||
<p className={styles.nodeConfigNameHint}>
|
||||
Wird im Data Picker angezeigt, um diesen Node zu identifizieren.
|
||||
{t('Wird im Data Picker angezeigt, um diesen Node zu identifizieren.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||
<ConfigRenderer
|
||||
params={params}
|
||||
updateParam={updateParam}
|
||||
{nodeType?.description && (
|
||||
<p className={styles.nodeConfigDescription}>
|
||||
{getLabel(nodeType.description, language)}
|
||||
</p>
|
||||
)}
|
||||
{parameters.map((param: NodeTypeParameter) => {
|
||||
const frontendType = param.frontendType || 'text';
|
||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
return (
|
||||
<Renderer
|
||||
key={param.name}
|
||||
param={param}
|
||||
value={params[param.name] ?? param.default}
|
||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
mergeNodeParameters={onMergeNodeParameters}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
src/components/FlowEditor/editor/NodeListItem.tsx
Normal file
53
src/components/FlowEditor/editor/NodeListItem.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* NodeListItem - Draggable node type item for the sidebar.
|
||||
* Used in both regular categories and I/O sub-groups.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeType } from '../../../api/workflowApi';
|
||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
interface NodeListItemProps {
|
||||
node: NodeType;
|
||||
language: string;
|
||||
getLabel: GetLabelFn;
|
||||
getCategoryIcon?: (categoryId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const NodeListItem: React.FC<NodeListItemProps> = ({
|
||||
node,
|
||||
language,
|
||||
getLabel,
|
||||
getCategoryIcon: getIcon = getCategoryIcon,
|
||||
}) => {
|
||||
const desc = getLabel(node.description, language);
|
||||
return (
|
||||
<div
|
||||
className={styles.nodeItem}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: node.id }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.nodeItemIcon}
|
||||
style={{
|
||||
backgroundColor: node.meta?.color
|
||||
? `${node.meta.color}20`
|
||||
: 'var(--bg-tertiary, #e9ecef)',
|
||||
color: node.meta?.color ?? 'var(--text-secondary, #666)',
|
||||
}}
|
||||
>
|
||||
{getIcon(node.category)}
|
||||
</div>
|
||||
<div className={styles.nodeItemInfo}>
|
||||
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
|
||||
<span className={styles.nodeItemDesc}>{desc}</span>
|
||||
</div>
|
||||
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,12 +5,14 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||
import type { NodeType, NodeTypeCategory } from '../../../api/automation2Api';
|
||||
import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
|
||||
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { NodeListItem } from './NodeListItem';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface NodeSidebarProps {
|
||||
nodeTypes: NodeType[];
|
||||
categories: NodeTypeCategory[];
|
||||
|
|
@ -21,10 +23,10 @@ interface NodeSidebarProps {
|
|||
onToggleCategory: (id: string) => void;
|
||||
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
|
||||
excludedCategories?: Set<string>;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||
nodeTypes,
|
||||
export const NodeSidebar: React.FC<NodeSidebarProps> = ({ nodeTypes,
|
||||
categories,
|
||||
filter,
|
||||
onFilterChange,
|
||||
|
|
@ -32,7 +34,9 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
|||
expandedCategories,
|
||||
onToggleCategory,
|
||||
excludedCategories,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const filteredNodeTypes = useMemo(() => {
|
||||
const visible = nodeTypes.filter(
|
||||
(n) =>
|
||||
|
|
@ -74,17 +78,17 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
|||
return result;
|
||||
}, [groupedByCategory]);
|
||||
|
||||
const getLabelFn = (t: string | Record<string, string> | undefined, lang?: string) =>
|
||||
getLabel(t, lang ?? language);
|
||||
const getLabelFn = (multilingual: string | Record<string, string> | undefined, lang?: string) =>
|
||||
getLabel(multilingual, lang ?? language);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.sidebar} style={style}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.sidebarSearch}
|
||||
placeholder="Nodes durchsuchen..."
|
||||
placeholder={t('Nodes durchsuchen')}
|
||||
value={filter}
|
||||
onChange={(e) => onFilterChange(e.target.value)}
|
||||
/>
|
||||
263
src/components/FlowEditor/editor/RunTracingPanel.tsx
Normal file
263
src/components/FlowEditor/editor/RunTracingPanel.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* RunTracingPanel
|
||||
*
|
||||
* Shows AutoStepLog entries for a workflow run with live SSE push.
|
||||
* Falls back to polling if SSE connection fails.
|
||||
* Displays per-node status, timing, I/O snapshots, and retry info.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import type { AutoStepLog } from '../../../api/workflowApi';
|
||||
import api from '../../../api';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface RunTracingPanelProps {
|
||||
instanceId: string;
|
||||
runId: string | null;
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
onActiveStepsChange?: (nodeStatuses: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#999',
|
||||
running: '#f0ad4e',
|
||||
completed: '#28a745',
|
||||
failed: '#dc3545',
|
||||
skipped: '#6c757d',
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
pending: '○',
|
||||
running: '◉',
|
||||
completed: '✓',
|
||||
failed: '✗',
|
||||
skipped: '—',
|
||||
};
|
||||
|
||||
function _formatTimestamp(ts: number | string | null | undefined): string {
|
||||
if (!ts) return '';
|
||||
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function _truncateJson(obj: unknown, maxLen = 300): string {
|
||||
if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return '';
|
||||
try {
|
||||
const s = JSON.stringify(obj, null, 2);
|
||||
return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s;
|
||||
} catch {
|
||||
return String(obj);
|
||||
}
|
||||
}
|
||||
|
||||
const CollapsibleSection: React.FC<{
|
||||
label: string; content: string;
|
||||
}> = ({ label, content }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: 'var(--text-link, #0969da)', fontSize: '11px', textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{open ? '▾' : '▸'} {label}
|
||||
</button>
|
||||
{open && (
|
||||
<pre style={{
|
||||
margin: '4px 0 0', padding: '6px', borderRadius: '4px',
|
||||
background: 'var(--bg-secondary, #f6f8fa)', fontSize: '11px',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '200px', overflowY: 'auto',
|
||||
}}>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
||||
instanceId,
|
||||
runId,
|
||||
onNodeSelect,
|
||||
onActiveStepsChange,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [steps, setSteps] = useState<AutoStepLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sseConnected, setSseConnected] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
if (!runId || !instanceId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
|
||||
method: 'get',
|
||||
});
|
||||
setSteps(data?.steps || []);
|
||||
} catch (e) {
|
||||
console.error('[RunTracing] Failed to load steps:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [runId, instanceId, request]);
|
||||
|
||||
// SSE live-push connection
|
||||
useEffect(() => {
|
||||
if (!runId || !instanceId) return;
|
||||
loadSteps();
|
||||
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = () => setSseConnected(true);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.type === 'keepalive') return;
|
||||
if (payload.type === 'run_complete' || payload.type === 'run_failed') {
|
||||
loadSteps();
|
||||
es.close();
|
||||
setSseConnected(false);
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'running') {
|
||||
setSteps((prev) => {
|
||||
const exists = prev.some((s) => s.id === payload.id);
|
||||
if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s);
|
||||
return [...prev, payload as AutoStepLog];
|
||||
});
|
||||
} else {
|
||||
setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s));
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
es.onerror = () => {
|
||||
setSseConnected(false);
|
||||
es.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setSseConnected(false);
|
||||
};
|
||||
}, [runId, instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fallback polling when SSE is not connected
|
||||
useEffect(() => {
|
||||
if (sseConnected || !runId || !instanceId) return;
|
||||
const interval = setInterval(loadSteps, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sseConnected, runId, instanceId, loadSteps]);
|
||||
|
||||
// Emit active node statuses for canvas highlighting
|
||||
useEffect(() => {
|
||||
if (!onActiveStepsChange) return;
|
||||
const nodeStatuses: Record<string, string> = {};
|
||||
for (const step of steps) {
|
||||
nodeStatuses[step.nodeId] = step.status;
|
||||
}
|
||||
onActiveStepsChange(nodeStatuses);
|
||||
}, [steps, onActiveStepsChange]);
|
||||
|
||||
if (!runId) {
|
||||
return (
|
||||
<div style={{ padding: '16px', color: 'var(--text-secondary, #888)', fontSize: '13px' }}>
|
||||
{t('Run auswählen, um Tracing-Details zu sehen.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', overflowY: 'auto', height: '100%' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '12px' }}>
|
||||
{t('Run-Schritte')}{' '}
|
||||
{loading && (
|
||||
<span style={{ fontWeight: 400, fontSize: '12px', color: '#888' }}>({t('wird geladen…')})</span>
|
||||
)}
|
||||
</div>
|
||||
{steps.length === 0 && !loading && (
|
||||
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>{t('Noch keine Schritte aufgezeichnet')}</div>
|
||||
)}
|
||||
{steps.map((step: any) => {
|
||||
const startStr = _formatTimestamp(step.startedAt);
|
||||
const endStr = _formatTimestamp(step.completedAt);
|
||||
const inputStr = _truncateJson(step.inputSnapshot);
|
||||
const outputStr = _truncateJson(step.output);
|
||||
const isLoop = step.inputSnapshot?._loopIndex != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
onClick={() => onNodeSelect?.(step.nodeId)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '6px',
|
||||
borderRadius: '6px',
|
||||
border: `1px solid ${STATUS_COLORS[step.status] || '#ddd'}`,
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
marginLeft: isLoop ? '16px' : '0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>
|
||||
<span style={{ color: STATUS_COLORS[step.status] || '#999', marginRight: '6px' }}>
|
||||
{STATUS_ICONS[step.status] || '?'}
|
||||
</span>
|
||||
<strong>{step.nodeType}</strong>
|
||||
<span style={{ color: '#888', marginLeft: '6px' }}>({step.nodeId})</span>
|
||||
{isLoop && (
|
||||
<span style={{ color: '#666', marginLeft: '6px', fontSize: '11px' }}>
|
||||
[iter {step.inputSnapshot._loopIndex}]
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{step.retryCount > 0 && (
|
||||
<span style={{ color: '#f0ad4e', fontSize: '11px' }} title={t('Wiederholungsanzahl')}>
|
||||
{step.retryCount}x {t('Wiederholung')}
|
||||
</span>
|
||||
)}
|
||||
{step.durationMs != null && (
|
||||
<span style={{ color: '#888', fontSize: '12px' }}>{step.durationMs}ms</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(startStr || endStr) && (
|
||||
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
|
||||
{startStr && <span>{startStr}</span>}
|
||||
{startStr && endStr && <span> → </span>}
|
||||
{endStr && <span>{endStr}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.error && (
|
||||
<div style={{ color: '#dc3545', fontSize: '12px', marginTop: '4px' }}>{step.error}</div>
|
||||
)}
|
||||
{step.tokensUsed > 0 && (
|
||||
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
|
||||
{step.tokensUsed} {t('Tokens')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CollapsibleSection label={t('Eingabe')} content={inputStr} />
|
||||
<CollapsibleSection label={t('Ausgabe')} content={outputStr} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
src/components/FlowEditor/editor/TemplatePicker.tsx
Normal file
155
src/components/FlowEditor/editor/TemplatePicker.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
import {
|
||||
fetchTemplates,
|
||||
type AutoWorkflowTemplate,
|
||||
type AutoTemplateScope,
|
||||
type ApiRequestFunction,
|
||||
} from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface TemplatePickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (templateId: string) => void;
|
||||
instanceId: string;
|
||||
request: ApiRequestFunction;
|
||||
}
|
||||
|
||||
export const TemplatePicker: React.FC<TemplatePickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const scopeLabels = useMemo(
|
||||
() =>
|
||||
({
|
||||
all: t('Alle'),
|
||||
user: t('Meine'),
|
||||
instance: t('Instanz'),
|
||||
mandate: t('Mandant'),
|
||||
system: t('System'),
|
||||
}) as Record<AutoTemplateScope | 'all', string>,
|
||||
[t]
|
||||
);
|
||||
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
|
||||
const [copying, setCopying] = useState<string | null>(null);
|
||||
|
||||
const _load = useCallback(async () => {
|
||||
if (!instanceId || !open) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const scope = activeScope === 'all' ? undefined : activeScope;
|
||||
const result = await fetchTemplates(request, instanceId, scope);
|
||||
setTemplates(Array.isArray(result) ? result : result.items);
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, request, open, activeScope]);
|
||||
|
||||
useEffect(() => {
|
||||
_load();
|
||||
}, [_load]);
|
||||
|
||||
const _handleSelect = useCallback(
|
||||
async (templateId: string) => {
|
||||
setCopying(templateId);
|
||||
try {
|
||||
await onSelect(templateId);
|
||||
} finally {
|
||||
setCopying(null);
|
||||
}
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="tpl-picker-title">
|
||||
<div className={styles.workflowModal} style={{ maxWidth: 600, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<h3 id="tpl-picker-title" className={styles.workflowModalTitle}>
|
||||
{t('Neu aus Vorlage')}
|
||||
</h3>
|
||||
<p className={styles.workflowModalHint}>
|
||||
{t('Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
{(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={activeScope === s ? styles.workflowModalBtnPrimary : styles.workflowModalBtnSecondary}
|
||||
onClick={() => setActiveScope(s)}
|
||||
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
|
||||
>
|
||||
{scopeLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 120 }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<FaSpinner className={styles.spinner} />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 24, color: 'var(--text-secondary, #888)' }}>
|
||||
{t('Keine Vorlagen gefunden.')}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--border-color, #e0e0e0)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '6px 8px' }}>{t('Name')}</th>
|
||||
<th style={{ padding: '6px 8px', width: 80 }}>{t('Scope')}</th>
|
||||
<th style={{ padding: '6px 8px', width: 100 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((tpl) => (
|
||||
<tr key={tpl.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||
<td style={{ padding: '8px' }}>{tpl.label}</td>
|
||||
<td style={{ padding: '8px', fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>
|
||||
{scopeLabels[(tpl.templateScope as AutoTemplateScope) || 'user']}
|
||||
</td>
|
||||
<td style={{ padding: '8px', textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.workflowModalBtnPrimary}
|
||||
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
|
||||
onClick={() => _handleSelect(tpl.id)}
|
||||
disabled={copying !== null}
|
||||
>
|
||||
{copying === tpl.id ? <FaSpinner className={styles.spinner} /> : t('Übernehmen')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.workflowModalActions} style={{ marginTop: 12 }}>
|
||||
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,20 +3,24 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { WorkflowEntryPoint } from '../../../api/automation2Api';
|
||||
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
|
||||
import {
|
||||
getPrimaryStartKind,
|
||||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
|
||||
const KIND_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'manual', label: 'Manueller Trigger' },
|
||||
{ value: 'form', label: 'Formular' },
|
||||
{ value: 'schedule', label: 'Zeitplan' },
|
||||
{ value: 'always_on', label: 'Immer aktiv' },
|
||||
];
|
||||
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'manual', label: t('Manueller Trigger') },
|
||||
{ value: 'form', label: t('Formular') },
|
||||
{ value: 'schedule', label: t('Zeitplan') },
|
||||
{ value: 'always_on', label: t('Immer aktiv') },
|
||||
];
|
||||
}
|
||||
|
||||
interface WorkflowConfigurationModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -25,19 +29,22 @@ interface WorkflowConfigurationModalProps {
|
|||
onApply: (next: WorkflowEntryPoint[]) => void;
|
||||
}
|
||||
|
||||
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
|
||||
|
||||
function normalizeLoadedKind(k: string): string {
|
||||
if (KIND_OPTIONS.some((o) => o.value === k)) return k;
|
||||
if (_validKinds.includes(k)) return k;
|
||||
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
|
||||
if (k === 'api') return 'manual';
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({
|
||||
open,
|
||||
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
|
||||
onClose,
|
||||
invocations,
|
||||
onApply,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const kindOptions = _getKindOptions(t);
|
||||
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
|
||||
const [titleDe, setTitleDe] = useState('');
|
||||
|
||||
|
|
@ -46,9 +53,9 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
|
|||
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
|
||||
setKind(k);
|
||||
const entry = invocations[0];
|
||||
const t = entry?.title;
|
||||
if (typeof t === 'string') setTitleDe(t);
|
||||
else if (t && typeof t === 'object') setTitleDe(t.de || t.en || '');
|
||||
const entryTitle = entry?.title;
|
||||
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
|
||||
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
|
||||
else setTitleDe('');
|
||||
}, [open, invocations]);
|
||||
|
||||
|
|
@ -57,7 +64,7 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
|
|||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const label =
|
||||
titleDe.trim() || KIND_OPTIONS.find((o) => o.value === kind)?.label || 'Start';
|
||||
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
|
||||
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
|
||||
onApply(next);
|
||||
onClose();
|
||||
|
|
@ -67,26 +74,27 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
|
|||
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
|
||||
<div className={styles.workflowModal}>
|
||||
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
|
||||
Workflow-Konfiguration
|
||||
{t('Workflow-Konfiguration')}
|
||||
</h3>
|
||||
<p className={styles.workflowModalHint}>
|
||||
Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem
|
||||
gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).
|
||||
{t(
|
||||
'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
|
||||
)}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
|
||||
Titel der Start Node
|
||||
{t('Titel der Start Node')}
|
||||
</label>
|
||||
<input
|
||||
id="wf-start-title"
|
||||
className={styles.workflowModalInput}
|
||||
value={titleDe}
|
||||
onChange={(e) => setTitleDe(e.target.value)}
|
||||
placeholder="z. B. Angebot anlegen"
|
||||
placeholder={t('z.B. Angebot anlegen')}
|
||||
/>
|
||||
|
||||
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label="Einstiegsart">
|
||||
{KIND_OPTIONS.map((o) => (
|
||||
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
|
||||
{kindOptions.map((o) => (
|
||||
<label key={o.value} className={styles.workflowModalRadio}>
|
||||
<input
|
||||
type="radio"
|
||||
|
|
@ -102,10 +110,10 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
|
|||
|
||||
<div className={styles.workflowModalActions}>
|
||||
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
||||
Abbrechen
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button type="submit" className={styles.workflowModalBtnPrimary}>
|
||||
Übernehmen
|
||||
{t('Übernehmen')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { Automation2FlowEditor } from './editor/Automation2FlowEditor';
|
||||
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
||||
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
||||
export { FlowCanvas } from './editor/FlowCanvas';
|
||||
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||
|
|
@ -8,4 +9,4 @@ export { CanvasHeader } from './editor/CanvasHeader';
|
|||
export * from './nodes/shared/utils';
|
||||
export * from './nodes/shared/constants';
|
||||
export * from './nodes/shared/graphUtils';
|
||||
export { getAcceptStringFromConfig } from './nodes/configs/UploadNodeConfig';
|
||||
export { getAcceptStringFromConfig } from './nodes/shared/utils';
|
||||
|
|
@ -4,16 +4,18 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||
import type { FormField, NodeConfigRendererProps } from '../configs/types';
|
||||
import { fetchConnections, type UserConnection } from '../../../../api/automation2Api';
|
||||
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||
params,
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||
updateParam,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const fields = (params.fields as FormField[]) ?? [];
|
||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||
|
|
@ -55,7 +57,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<label>Felder</label>
|
||||
<label>{t('Felder')}</label>
|
||||
<div className={styles.formFieldsList}>
|
||||
{fields.map((f, i) => (
|
||||
<div
|
||||
|
|
@ -74,7 +76,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
<div className={styles.formFieldRowHeader}>
|
||||
<span
|
||||
className={styles.formFieldDragHandle}
|
||||
title="Zum Verschieben ziehen"
|
||||
title={t('Zum Verschieben ziehen')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', String(i));
|
||||
|
|
@ -85,7 +87,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
</span>
|
||||
<div className={styles.formFieldInputs}>
|
||||
<input
|
||||
placeholder="name"
|
||||
placeholder={t('name')}
|
||||
value={f.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
|
|
@ -94,7 +96,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="label"
|
||||
placeholder={t('label')}
|
||||
value={f.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
|
|
@ -109,13 +111,13 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
value={f.type ?? 'string'}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
const t = e.target.value;
|
||||
const fieldType = e.target.value;
|
||||
next[i] = {
|
||||
...next[i],
|
||||
type: t,
|
||||
...(t === 'clickup_tasks'
|
||||
type: fieldType,
|
||||
...(fieldType === 'clickup_tasks'
|
||||
? { clickupStatusOptions: undefined }
|
||||
: t === 'clickup_status'
|
||||
: fieldType === 'clickup_status'
|
||||
? { clickupConnectionId: undefined, clickupListId: undefined }
|
||||
: {
|
||||
clickupConnectionId: undefined,
|
||||
|
|
@ -127,12 +129,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
}}
|
||||
style={{ width: 'auto', minWidth: 90 }}
|
||||
>
|
||||
<option value="string">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="boolean">Checkbox</option>
|
||||
<option value="clickup_tasks">ClickUp-Aufgabe (Referenz)</option>
|
||||
<option value="clickup_status">ClickUp-Status (Liste)</option>
|
||||
<option value="string">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="boolean">{t('Kontrollkästchen')}</option>
|
||||
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
|
||||
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||
</select>
|
||||
<label className={styles.formFieldRequiredLabel}>
|
||||
<input
|
||||
|
|
@ -144,12 +146,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
updateParam('fields', next);
|
||||
}}
|
||||
/>
|
||||
Pflichtfeld
|
||||
{t('Pflichtfeld')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeField(i)}
|
||||
title="Feld entfernen"
|
||||
title={t('Feld entfernen')}
|
||||
className={styles.formFieldRemoveButton}
|
||||
>
|
||||
<FaTimes />
|
||||
|
|
@ -159,13 +161,16 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
|
||||
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
|
||||
<p style={{ margin: '0 0 6px' }}>
|
||||
Dropdown mit {f.clickupStatusOptions.length} Status aus der ClickUp-Liste (Wert = exakter
|
||||
Status-Name für die API).
|
||||
{t(
|
||||
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
|
||||
{ count: String(f.clickupStatusOptions.length) }
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ margin: '0 0 6px' }}>
|
||||
Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste
|
||||
abgleichen“.
|
||||
{t(
|
||||
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -173,7 +178,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
{f.type === 'clickup_tasks' ? (
|
||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
||||
ClickUp-Verbindung
|
||||
{t('ClickUp-Verbindung')}
|
||||
</label>
|
||||
<select
|
||||
value={f.clickupConnectionId ?? ''}
|
||||
|
|
@ -185,7 +190,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
disabled={connectionsLoading || !instanceId}
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
>
|
||||
<option value="">{connectionsLoading ? 'Lade…' : 'Verbindung wählen…'}</option>
|
||||
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.externalUsername ?? c.id}
|
||||
|
|
@ -193,10 +198,10 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
))}
|
||||
</select>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
||||
Listen-ID (verknüpfte Liste / Ziel-Liste)
|
||||
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
|
||||
</label>
|
||||
<input
|
||||
placeholder="z. B. aus ClickUp-URL …/list/123456789"
|
||||
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
|
||||
value={f.clickupListId ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
|
|
@ -206,9 +211,9 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
style={{ width: '100%' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
|
||||
Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:{' '}
|
||||
<code>{'{ add: [taskId], rem: [] }'}</code> — im ClickUp-Node per Datenquelle auf das
|
||||
Formularfeld mappen.
|
||||
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
|
||||
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
|
||||
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -220,7 +225,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
|||
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
||||
}
|
||||
>
|
||||
+ Feld
|
||||
+ {t('Feld')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
418
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
Normal file
418
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
/**
|
||||
* Generic FrontendType renderer registry.
|
||||
* Maps frontendType strings to React components.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
param: NodeTypeParameter;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
allParams?: Record<string, unknown>;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline renderers for standard types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : (value != null ? String(value) : '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.name}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TextareaInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<textarea
|
||||
value={typeof value === 'string' ? value : (value != null ? String(value) : '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.name}
|
||||
rows={4}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NumberInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={typeof value === 'number' ? value : (value != null ? Number(value) || 0 : '')}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CheckboxInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<label style={{ fontSize: 12 }}>{param.description || param.name}</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const options: string[] =
|
||||
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const options: string[] =
|
||||
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
const toggle = (opt: string) => {
|
||||
const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt];
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} />
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JsonEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const strVal = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2);
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<textarea
|
||||
value={strVal}
|
||||
onChange={(e) => {
|
||||
try { onChange(JSON.parse(e.target.value)); } catch { onChange(e.target.value); }
|
||||
}}
|
||||
rows={6}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace', fontSize: 11, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HiddenInput: React.FC<FieldRendererProps> = () => null;
|
||||
|
||||
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
|
||||
const { t } = useLanguage();
|
||||
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
||||
React.useEffect(() => {
|
||||
if (!instanceId || !request) return;
|
||||
request({ url: `/api/graphicalEditor/${instanceId}/options/user.connection`, method: 'get' })
|
||||
.then((res: unknown) => {
|
||||
const data = res as { options?: Array<{ value: string; label: string }> };
|
||||
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [instanceId, request]);
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">{t('Verbindung wählen')}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
|
||||
const { t } = useLanguage();
|
||||
const dependsOn = param.frontendOptions?.dependsOn as string | undefined;
|
||||
const depValue = dependsOn ? allParams?.[dependsOn] : undefined;
|
||||
const disabled = dependsOn && !depValue;
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={!!disabled}
|
||||
placeholder={disabled ? t('Zuerst {field} wählen', { field: dependsOn ?? '' }) : param.name}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: disabled ? 0.5 : 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const cases = Array.isArray(value) ? value : [];
|
||||
const addCase = () => onChange([...cases, { operator: 'eq', value: '' }]);
|
||||
const removeCase = (idx: number) => onChange(cases.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateCase = (idx: number, field: string, val: unknown) => {
|
||||
const next = [...cases];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{cases.map((c: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<select value={String(c.operator || 'eq')} onChange={(e) => updateCase(i, 'operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="eq">{t('ist gleich')}</option>
|
||||
<option value="neq">{t('ungleich')}</option>
|
||||
<option value="contains">{t('enthält')}</option>
|
||||
<option value="gt">{t('größer als')}</option>
|
||||
<option value="lt">{t('kleiner als')}</option>
|
||||
</select>
|
||||
<input type="text" value={String(c.value ?? '')} onChange={(e) => updateCase(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<button onClick={() => removeCase(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addCase} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Fall hinzufügen')}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const fields = Array.isArray(value) ? value : [];
|
||||
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]);
|
||||
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateField = (idx: number, field: string, val: unknown) => {
|
||||
const next = [...fields];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{fields.map((f: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
|
||||
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="text">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="checkbox">{t('Kontrollkästchen')}</option>
|
||||
<option value="select">{t('Auswahl')}</option>
|
||||
<option value="textarea">{t('Mehrzeilig')}</option>
|
||||
</select>
|
||||
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
|
||||
</label>
|
||||
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
const addRow = () => onChange([...rows, { key: '', value: '' }]);
|
||||
const removeRow = (idx: number) => onChange(rows.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateRow = (idx: number, field: string, val: string) => {
|
||||
const next = [...rows];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{rows.map((r: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<input type="text" placeholder={t('Schlüssel')} value={String(r.key ?? r.fieldKey ?? '')} onChange={(e) => updateRow(i, 'key', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<input type="text" placeholder={t('Wert')} value={String(r.value ?? '')} onChange={(e) => updateRow(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<button onClick={() => removeRow(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addRow} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Zeile hinzufügen')}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t('0 9 * * *')}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
|
||||
/>
|
||||
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
|
||||
const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val });
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="eq">{t('ist gleich')}</option>
|
||||
<option value="neq">{t('ungleich')}</option>
|
||||
<option value="gt">{t('größer als')}</option>
|
||||
<option value="lt">{t('kleiner als')}</option>
|
||||
<option value="contains">{t('enthält')}</option>
|
||||
<option value="empty">{t('ist leer')}</option>
|
||||
<option value="not_empty">{t('ist nicht leer')}</option>
|
||||
<option value="is_true">{t('ist wahr')}</option>
|
||||
<option value="is_false">{t('ist falsch')}</option>
|
||||
</select>
|
||||
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const mappings = Array.isArray(value) ? value : [];
|
||||
const addMapping = () => onChange([...mappings, { sourceField: '', outputField: '' }]);
|
||||
const removeMapping = (idx: number) => onChange(mappings.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateMapping = (idx: number, field: string, val: string) => {
|
||||
const next = [...mappings];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{mappings.map((m: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<input type="text" placeholder={t('Quellfeld')} value={String(m.sourceField ?? '')} onChange={(e) => updateMapping(i, 'sourceField', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<span style={{ alignSelf: 'center' }}>→</span>
|
||||
<input type="text" placeholder={t('Ausgabefeld')} value={String(m.outputField ?? '')} onChange={(e) => updateMapping(i, 'outputField', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<button onClick={() => removeMapping(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addMapping} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Zuordnung hinzufügen')}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
|
||||
const update = (field: string, val: unknown) => onChange({ ...cond, [field]: val });
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" placeholder={t('Feld')} value={String(cond.field ?? '')} onChange={(e) => update('field', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="eq">{t('ist gleich')}</option>
|
||||
<option value="neq">{t('ungleich')}</option>
|
||||
<option value="contains">{t('enthält')}</option>
|
||||
<option value="startsWith">{t('beginnt mit')}</option>
|
||||
<option value="isEmpty">{t('ist leer')}</option>
|
||||
<option value="isNotEmpty">{t('ist nicht leer')}</option>
|
||||
<option value="gt">{t('größer als')}</option>
|
||||
<option value="lt">{t('kleiner als')}</option>
|
||||
</select>
|
||||
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||
text: TextInput,
|
||||
textarea: TextareaInput,
|
||||
number: NumberInput,
|
||||
checkbox: CheckboxInput,
|
||||
date: DateInput,
|
||||
datetime: DateInput,
|
||||
email: TextInput,
|
||||
select: SelectInput,
|
||||
multiselect: MultiSelectInput,
|
||||
json: JsonEditor,
|
||||
file: TextInput,
|
||||
hidden: HiddenInput,
|
||||
userConnection: ConnectionPicker,
|
||||
sharepointFolder: FolderPicker,
|
||||
sharepointFile: FolderPicker,
|
||||
clickupList: FolderPicker,
|
||||
clickupTask: FolderPicker,
|
||||
caseList: CaseListEditor,
|
||||
fieldBuilder: FieldBuilderEditor,
|
||||
keyValueRows: KeyValueRowsEditor,
|
||||
cron: CronBuilder,
|
||||
condition: ConditionBuilder,
|
||||
mappingTable: MappingTableEditor,
|
||||
filterExpression: FilterExpressionEditor,
|
||||
};
|
||||
|
||||
export default FRONTEND_TYPE_RENDERERS;
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from '../configs/types';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { isRef } from '../shared/dataRef';
|
||||
|
|
@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
|
|||
import { operatorsForType } from '../shared/conditionOperators';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export interface StructuredCondition {
|
||||
type: 'condition';
|
||||
ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null;
|
||||
|
|
@ -28,6 +30,7 @@ function parseCondition(v: unknown): StructuredCondition | null {
|
|||
}
|
||||
|
||||
export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
|
||||
const cond = parseCondition(params.condition);
|
||||
|
|
@ -96,8 +99,8 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
return (
|
||||
<div className={styles.ifElseConditionEditor}>
|
||||
<div className={styles.ifElseConditionRow}>
|
||||
<label>Datenquelle</label>
|
||||
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder="Formular-Feld wählen…" />
|
||||
<label>{t('Datenquelle')}</label>
|
||||
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('Formularfeld wählen')} />
|
||||
</div>
|
||||
<div className={styles.ifElseConditionRow}>
|
||||
<label>Vergleich</label>
|
||||
|
|
@ -111,13 +114,13 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
</div>
|
||||
{needsValue && (
|
||||
<div className={styles.ifElseConditionRow}>
|
||||
<label>Wert</label>
|
||||
<label>{t('Wert')}</label>
|
||||
{mimeTypeOptions.length > 0 ? (
|
||||
<select
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
>
|
||||
<option value="">— MIME-Type wählen —</option>
|
||||
<option value="">{t('MIME-Typ wählen')}</option>
|
||||
{mimeTypeOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label} ({o.value})
|
||||
|
|
@ -139,8 +142,8 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
: fieldType === 'date'
|
||||
? 'TT.MM.JJJJ'
|
||||
: isMimeTypeRef
|
||||
? 'z.B. application/pdf'
|
||||
: 'z.B. CH'
|
||||
? t('z.B. application/pdf')
|
||||
: t('z.B. ch')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from '../configs/types';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import { LoopItemsSelect } from '../shared/LoopItemsSelect';
|
||||
import { createValue, isRef, isValue } from '../shared/dataRef';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
*/
|
||||
|
||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { NodeType } from '../../../../api/automation2Api';
|
||||
import type { WorkflowEntryPoint } from '../../../../api/automation2Api';
|
||||
import type { NodeType } from '../../../../api/workflowApi';
|
||||
import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
|
||||
import { getLabel } from '../shared/utils';
|
||||
|
||||
export const CANVAS_START_NODE_ID = 'start';
|
||||
251
src/components/FlowEditor/nodes/shared/DataPicker.tsx
Normal file
251
src/components/FlowEditor/nodes/shared/DataPicker.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Schema-based Data Picker.
|
||||
* Builds pickable paths from portTypeCatalog + node outputPorts.
|
||||
* Resolves Transit chains to show the real upstream schema.
|
||||
* Includes a System Variables section.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
interface DataPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (ref: DataRef | SystemVarRef) => void;
|
||||
availableSourceIds: string[];
|
||||
nodes: Array<{ id: string; title?: string; type?: string }>;
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
getNodeLabel: (node: { id: string; title?: string }) => string;
|
||||
}
|
||||
|
||||
interface PickablePath {
|
||||
path: (string | number)[];
|
||||
label: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function _buildPathsFromSchema(
|
||||
schema: PortSchema | undefined,
|
||||
basePath: (string | number)[] = [],
|
||||
): PickablePath[] {
|
||||
if (!schema || !schema.fields) return [];
|
||||
const result: PickablePath[] = [];
|
||||
for (const field of schema.fields) {
|
||||
const fieldPath = [...basePath, field.name];
|
||||
const label = fieldPath.map(String).join(' → ');
|
||||
result.push({ path: fieldPath, label, type: field.type });
|
||||
}
|
||||
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
|
||||
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
||||
return result;
|
||||
}
|
||||
|
||||
function _buildPathsFromPreview(
|
||||
obj: unknown,
|
||||
basePath: (string | number)[] = [],
|
||||
wholeOutputLabel = '(ganze Ausgabe)',
|
||||
): PickablePath[] {
|
||||
const pathLabel = basePath.length ? basePath.map(String).join(' → ') : wholeOutputLabel;
|
||||
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
|
||||
return [{ path: [...basePath], label: pathLabel }];
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
|
||||
for (let i = 0; i < Math.min(obj.length, 5); i++) {
|
||||
result.push(..._buildPathsFromPreview(obj[i], [...basePath, i], wholeOutputLabel));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (k.startsWith('_')) continue;
|
||||
result.push(..._buildPathsFromPreview(v, [...basePath, k], wholeOutputLabel));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return [{ path: [...basePath], label: pathLabel }];
|
||||
}
|
||||
|
||||
function _resolveSchemaForNode(
|
||||
nodeId: string,
|
||||
nodes: Array<{ id: string; type?: string }>,
|
||||
nodeTypes: NodeType[],
|
||||
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
||||
catalog: Record<string, PortSchema>,
|
||||
visited: Set<string> = new Set(),
|
||||
): PortSchema | undefined {
|
||||
if (visited.has(nodeId)) return undefined;
|
||||
visited.add(nodeId);
|
||||
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return undefined;
|
||||
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
||||
if (!typeDef?.outputPorts) return undefined;
|
||||
|
||||
const port0 = typeDef.outputPorts[0];
|
||||
if (!port0) return undefined;
|
||||
|
||||
if (port0.schema !== 'Transit') {
|
||||
return catalog[port0.schema];
|
||||
}
|
||||
|
||||
// Transit: follow the incoming connection to find the real producer
|
||||
const incoming = connections.find((c) => c.target === nodeId);
|
||||
if (!incoming) return undefined;
|
||||
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited);
|
||||
}
|
||||
|
||||
export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||
onClose,
|
||||
onPick,
|
||||
availableSourceIds,
|
||||
nodes,
|
||||
nodeOutputsPreview,
|
||||
getNodeLabel,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [showSystem, setShowSystem] = useState(false);
|
||||
const ctx = useAutomation2DataFlow();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const catalog = ctx?.portTypeCatalog ?? {};
|
||||
const systemVars = ctx?.systemVariables ?? {};
|
||||
const nodeTypes = ctx?.nodeTypes ?? [];
|
||||
const connectionsRaw = ctx?.connections ?? [];
|
||||
const connections = connectionsRaw.map((c) => ({
|
||||
source: c.sourceId,
|
||||
target: c.targetId,
|
||||
sourceOutput: c.sourceHandle,
|
||||
}));
|
||||
|
||||
const toggleExpand = (nodeId: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePick = (nodeId: string, path: (string | number)[]) => {
|
||||
onPick(createRef(nodeId, path));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePickSystemVar = (variable: string) => {
|
||||
onPick(createSystemVar(variable));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dataPickerOverlay} onClick={onClose}>
|
||||
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.dataPickerHeader}>
|
||||
<h4 className={styles.dataPickerTitle}>{t('Datenquelle wählen')}</h4>
|
||||
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.dataPickerBody}>
|
||||
{/* System Variables Section */}
|
||||
{Object.keys(systemVars).length > 0 && (
|
||||
<div className={styles.dataPickerNodeSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dataPickerNodeHeader}
|
||||
onClick={() => setShowSystem(!showSystem)}
|
||||
>
|
||||
<span className={styles.dataPickerExpandIcon}>{showSystem ? '▼' : '▶'}</span>
|
||||
<span className={styles.dataPickerNodeLabel}>{t('System')}</span>
|
||||
</button>
|
||||
{showSystem && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
{Object.entries(systemVars).map(([key, info]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
onClick={() => handlePickSystemVar(key)}
|
||||
title={info.description}
|
||||
>
|
||||
{key} <span style={{ color: '#888', fontSize: 10 }}>({info.type})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node outputs */}
|
||||
{(() => {
|
||||
const filteredIds = availableSourceIds.filter((nodeId) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
return node?.type !== 'trigger.manual';
|
||||
});
|
||||
if (filteredIds.length === 0 && Object.keys(systemVars).length === 0) {
|
||||
return <p className={styles.dataPickerEmpty}>{t('Keine vorherigen Nodes verfügbar')}</p>;
|
||||
}
|
||||
return filteredIds.map((nodeId) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const label = node ? getNodeLabel(node) : nodeId;
|
||||
const isExpanded = expandedNodes.has(nodeId);
|
||||
|
||||
const resolvedSchema = _resolveSchemaForNode(
|
||||
nodeId, nodes, nodeTypes, connections, catalog,
|
||||
);
|
||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
|
||||
const paths = schemaPaths.length > 0
|
||||
? schemaPaths
|
||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
|
||||
|
||||
return (
|
||||
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dataPickerNodeHeader}
|
||||
onClick={() => toggleExpand(nodeId)}
|
||||
>
|
||||
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
|
||||
<span className={styles.dataPickerNodeLabel}>{label}</span>
|
||||
{resolvedSchema && (
|
||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||
({resolvedSchema.name})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
{paths.map((p, i) => (
|
||||
<button
|
||||
key={`${p.path.join('.')}-${i}`}
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
onClick={() => handlePick(nodeId, p.path)}
|
||||
>
|
||||
{p.label}
|
||||
{p.type && (
|
||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||
({p.type})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,12 +8,15 @@ import {
|
|||
createValue,
|
||||
formatRefLabel,
|
||||
type DataRef,
|
||||
type SystemVarRef,
|
||||
} from './dataRef';
|
||||
import { RefSourceSelect } from './RefSourceSelect';
|
||||
import { DataPicker } from './DataPicker';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export type FieldType = 'textarea' | 'input';
|
||||
|
||||
interface DynamicValueFieldProps {
|
||||
|
|
@ -28,14 +31,14 @@ interface DynamicValueFieldProps {
|
|||
variant?: 'picker' | 'dropdown';
|
||||
}
|
||||
|
||||
export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
|
||||
paramKey,
|
||||
export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({ paramKey,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
variant = 'picker',
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
|
|
@ -47,7 +50,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
|
|||
});
|
||||
const canUseRef = dataFlow !== null && hasUsefulSources;
|
||||
|
||||
const handleSetRef = (newRef: DataRef | null) => {
|
||||
const handleSetRef = (newRef: DataRef | SystemVarRef | null) => {
|
||||
onChange(paramKey, newRef ?? createValue(''));
|
||||
};
|
||||
|
||||
|
|
@ -55,7 +58,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
|
|||
return (
|
||||
<div className={styles.dynamicValueField}>
|
||||
<label>{label}</label>
|
||||
<p className={styles.dynamicValueEmptyHint}>Keine vorherigen Nodes verfügbar.</p>
|
||||
<p className={styles.dynamicValueEmptyHint}>{t('Keine vorherigen Nodes verfügbar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext
|
|||
import { isRef, isValue, createValue } from './dataRef';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
function parseHybrid(value: unknown): { staticStr: string } {
|
||||
if (isRef(value)) return { staticStr: '' };
|
||||
if (isValue(value)) {
|
||||
|
|
@ -37,8 +39,7 @@ export interface HybridStaticRefFieldProps {
|
|||
pathPickMode?: PathPickMode;
|
||||
}
|
||||
|
||||
export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
|
||||
label,
|
||||
export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({ label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
|
|
@ -46,6 +47,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
|
|||
placeholder,
|
||||
pathPickMode = 'default',
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
const hasSources =
|
||||
dataFlow &&
|
||||
|
|
@ -83,7 +85,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
|
|||
<StatischKontextSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="— Quelle wählen —"
|
||||
placeholder={t('Quelle wählen')}
|
||||
pathPickMode={pathPickMode}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -9,6 +9,8 @@ import { refToOptionValue, optionValueToRef } from './RefSourceSelect';
|
|||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
interface LoopOption {
|
||||
ref: DataRef;
|
||||
label: string;
|
||||
|
|
@ -33,7 +35,8 @@ function buildLoopOptions(
|
|||
sourceIds: string[],
|
||||
nodes: Array<{ id: string; type?: string; title?: string; parameters?: Record<string, unknown> }>,
|
||||
nodeOutputsPreview: Record<string, unknown>,
|
||||
getNodeLabel: (n: { id: string; type?: string; title?: string }) => string
|
||||
getNodeLabel: (n: { id: string; type?: string; title?: string }) => string,
|
||||
translate: (key: string) => string
|
||||
): LoopOption[] {
|
||||
const options: LoopOption[] = [];
|
||||
|
||||
|
|
@ -48,13 +51,13 @@ function buildLoopOptions(
|
|||
if (node?.type === 'trigger.form') {
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['payload']),
|
||||
label: `Alle Formularfelder (${nodeLabel})`,
|
||||
label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
|
||||
});
|
||||
const filesVal = getValueAtPath(preview, ['files']);
|
||||
if (Array.isArray(filesVal)) {
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['files']),
|
||||
label: `Alle Dateien aus Formular (${nodeLabel})`,
|
||||
label: `${translate('Alle Dateien aus Formular')} (${nodeLabel})`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
|
|
@ -63,7 +66,7 @@ function buildLoopOptions(
|
|||
if (node?.type === 'input.form') {
|
||||
options.push({
|
||||
ref: createRef(nodeId, []),
|
||||
label: `Alle Formularfelder (${nodeLabel})`,
|
||||
label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -71,11 +74,11 @@ function buildLoopOptions(
|
|||
if (node?.type === 'input.upload') {
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['files']),
|
||||
label: `Alle hochgeladenen Dateien (${nodeLabel})`,
|
||||
label: `${translate('Alle hochgeladenen Dateien')} (${nodeLabel})`,
|
||||
});
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['fileIds']),
|
||||
label: `Alle Datei-IDs (${nodeLabel})`,
|
||||
label: `${translate('Alle Datei-IDs')} (${nodeLabel})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -83,7 +86,7 @@ function buildLoopOptions(
|
|||
if (node?.type === 'flow.loop') {
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['items']),
|
||||
label: `Alle Elemente aus Schleife (${nodeLabel})`,
|
||||
label: `${translate('Alle Elemente aus Schleife')} (${nodeLabel})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -91,7 +94,7 @@ function buildLoopOptions(
|
|||
if (node?.type === 'email.searchEmail') {
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['data', 'searchResults', 'results']),
|
||||
label: `Alle gefundenen E-Mails (${nodeLabel})`,
|
||||
label: `${translate('Alle gefundenen E-Mails')} (${nodeLabel})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -99,7 +102,7 @@ function buildLoopOptions(
|
|||
if (node?.type === 'email.checkEmail') {
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['data', 'emails', 'emails']),
|
||||
label: `Alle E-Mails (${nodeLabel})`,
|
||||
label: `${translate('Alle E-Mails')} (${nodeLabel})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -107,7 +110,7 @@ function buildLoopOptions(
|
|||
if (node?.type === 'sharepoint.listFiles') {
|
||||
options.push({
|
||||
ref: createRef(nodeId, ['files']),
|
||||
label: `Alle Dateien (${nodeLabel})`,
|
||||
label: `${translate('Alle Dateien')} (${nodeLabel})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -153,11 +156,11 @@ interface LoopItemsSelectProps {
|
|||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
||||
value,
|
||||
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
|
||||
onChange,
|
||||
placeholder = 'Über was soll iteriert werden?',
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
if (!dataFlow) return null;
|
||||
|
||||
|
|
@ -165,7 +168,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
|||
if (sourceIds.length === 0) {
|
||||
return (
|
||||
<p className={styles.dynamicValueEmptyHint}>
|
||||
Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife.
|
||||
{t('Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife.')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -174,7 +177,8 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
|||
sourceIds,
|
||||
dataFlow.nodes,
|
||||
dataFlow.nodeOutputsPreview,
|
||||
dataFlow.getNodeLabel
|
||||
dataFlow.getNodeLabel,
|
||||
t
|
||||
);
|
||||
|
||||
const ref = isRef(value) ? value : null;
|
||||
|
|
@ -182,7 +186,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
|||
|
||||
return (
|
||||
<div className={styles.ifElseConditionRow}>
|
||||
<label>Datenquelle für Iteration</label>
|
||||
<label>{t('Datenquelle für Iteration')}</label>
|
||||
<select
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
|
|
@ -196,7 +200,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
|||
}}
|
||||
className={styles.startsInput}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
<option value="">{placeholder ?? t('Über was soll iteriert werden?')}</option>
|
||||
{options.map((o) => (
|
||||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||||
{o.label}
|
||||
|
|
@ -204,7 +208,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
|||
))}
|
||||
</select>
|
||||
<p className={styles.nodeConfigNameHint}>
|
||||
Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.
|
||||
{t('Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import React from 'react';
|
||||
import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
/** How to build path options for StatischKontextSelect / RefSourceSelect. */
|
||||
export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
|
||||
|
|
@ -131,6 +132,11 @@ export function refToOptionValue(ref: DataRef): string {
|
|||
return JSON.stringify(ref);
|
||||
}
|
||||
|
||||
function _pathLabelForDisplay(pathLabel: string, translate: (key: string) => string): string {
|
||||
if (pathLabel === 'Aufgaben-ID') return translate('Aufgaben-ID');
|
||||
return pathLabel;
|
||||
}
|
||||
|
||||
export function optionValueToRef(s: string): DataRef | null {
|
||||
try {
|
||||
const o = JSON.parse(s) as unknown;
|
||||
|
|
@ -190,10 +196,11 @@ interface StatischKontextSelectProps {
|
|||
export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '— Quelle wählen —',
|
||||
staticLabel = 'Statisch',
|
||||
placeholder,
|
||||
staticLabel,
|
||||
pathPickMode = 'default',
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
if (!dataFlow) return null;
|
||||
|
||||
|
|
@ -213,7 +220,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
|||
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
||||
const paths = pickPathsForNode(node, preview, pathPickMode);
|
||||
for (const p of paths) {
|
||||
const displayLabel = p.pathLabel ? `${nodeLabel} → ${p.pathLabel}` : nodeLabel;
|
||||
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
|
||||
const displayLabel = pathLabelUi ? `${nodeLabel} → ${pathLabelUi}` : nodeLabel;
|
||||
options.push({
|
||||
ref: createRef(nodeId, p.path),
|
||||
label: displayLabel,
|
||||
|
|
@ -245,8 +253,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
|||
if (ref) onChange(ref);
|
||||
}}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
<option value={STATIC_SOURCE_VALUE}>{staticLabel}</option>
|
||||
<option value="">{placeholder ?? t('— Quelle wählen —')}</option>
|
||||
<option value={STATIC_SOURCE_VALUE}>{staticLabel ?? t('Statisch')}</option>
|
||||
{options.map((o) => (
|
||||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||||
{o.label}
|
||||
|
|
@ -267,9 +275,10 @@ interface RefSourceSelectProps {
|
|||
export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Datenquelle wählen…',
|
||||
placeholder,
|
||||
pathPickMode = 'default',
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
if (!dataFlow) return null;
|
||||
|
||||
|
|
@ -289,7 +298,8 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
|||
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
||||
const paths = pickPathsForNode(node, preview, pathPickMode);
|
||||
for (const p of paths) {
|
||||
const displayLabel = p.pathLabel ? `${nodeLabel} → ${p.pathLabel}` : nodeLabel;
|
||||
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
|
||||
const displayLabel = pathLabelUi ? `${nodeLabel} → ${pathLabelUi}` : nodeLabel;
|
||||
options.push({
|
||||
ref: createRef(nodeId, p.path),
|
||||
label: displayLabel,
|
||||
|
|
@ -312,7 +322,7 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
|||
if (ref) onChange(ref);
|
||||
}}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
<option value="">{placeholder ?? t('Datenquelle wählen…')}</option>
|
||||
{options.map((o) => (
|
||||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||||
{o.label}
|
||||
|
|
@ -343,13 +353,13 @@ function getFormFieldType(
|
|||
if (!fieldName) return null;
|
||||
const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName);
|
||||
if (!field || typeof field !== 'object') return null;
|
||||
const t = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
|
||||
if (t === 'number') return 'number';
|
||||
if (t === 'email') return 'email';
|
||||
if (t === 'date' || t === 'datetime') return 'date';
|
||||
if (t === 'boolean' || t === 'checkbox') return 'boolean';
|
||||
if (t === 'clickup_tasks') return 'string';
|
||||
if (t === 'clickup_status') return 'string';
|
||||
const rawFieldType = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
|
||||
if (rawFieldType === 'number') return 'number';
|
||||
if (rawFieldType === 'email') return 'email';
|
||||
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
|
||||
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
|
||||
if (rawFieldType === 'clickup_tasks') return 'string';
|
||||
if (rawFieldType === 'clickup_status') return 'string';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
|
|
@ -16,10 +16,25 @@ export interface DataValue {
|
|||
value: unknown;
|
||||
}
|
||||
|
||||
/** Union: either a reference or a static value */
|
||||
export type DynamicValue = DataRef | DataValue;
|
||||
/** System variable reference */
|
||||
export interface SystemVarRef {
|
||||
type: 'system';
|
||||
variable: string;
|
||||
}
|
||||
|
||||
/** Union: reference, static value, or system variable */
|
||||
export type DynamicValue = DataRef | DataValue | SystemVarRef;
|
||||
|
||||
/** Type guards */
|
||||
export function isSystemVar(v: unknown): v is SystemVarRef {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
(v as SystemVarRef).type === 'system' &&
|
||||
typeof (v as SystemVarRef).variable === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function isRef(v: unknown): v is DataRef {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
|
|
@ -39,7 +54,12 @@ export function isValue(v: unknown): v is DataValue {
|
|||
}
|
||||
|
||||
export function isDynamicValue(v: unknown): v is DynamicValue {
|
||||
return isRef(v) || isValue(v);
|
||||
return isRef(v) || isValue(v) || isSystemVar(v);
|
||||
}
|
||||
|
||||
/** Create a system variable reference */
|
||||
export function createSystemVar(variable: string): SystemVarRef {
|
||||
return { type: 'system', variable };
|
||||
}
|
||||
|
||||
/** Create a reference object */
|
||||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
Automation2Graph,
|
||||
Automation2GraphNode,
|
||||
Automation2Connection,
|
||||
} from '../../../../api/automation2Api';
|
||||
} from '../../../../api/workflowApi';
|
||||
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
||||
|
||||
export function fromApiGraph(
|
||||
|
|
@ -27,6 +27,7 @@ export function fromApiGraph(
|
|||
const cases = (n.parameters?.cases as unknown[]) ?? [];
|
||||
outputs = Math.max(1, cases.length);
|
||||
}
|
||||
const nt = nodeTypes.find((t) => t.id === n.type);
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
|
|
@ -37,6 +38,12 @@ export function fromApiGraph(
|
|||
inputs: io.inputs,
|
||||
outputs,
|
||||
parameters: n.parameters ?? {},
|
||||
inputPorts: nt?.inputPorts
|
||||
? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts }))
|
||||
: undefined,
|
||||
outputPorts: nt?.outputPorts
|
||||
? Object.entries(nt.outputPorts).map(([, v]) => ({ name: '', schema: (v as { schema?: string }).schema ?? '' }))
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -71,6 +78,8 @@ export function toApiGraph(
|
|||
title: n.title,
|
||||
comment: n.comment,
|
||||
parameters: n.parameters ?? {},
|
||||
inputPorts: n.inputPorts,
|
||||
outputPorts: n.outputPorts,
|
||||
})),
|
||||
connections: connections.map((c) => {
|
||||
const srcNode = nodeMap.get(c.sourceId);
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Schema-based output preview builders.
|
||||
* Derives preview trees from portTypeCatalog + node outputPorts.
|
||||
*/
|
||||
|
||||
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { PortSchema, NodeType } from '../../../../api/workflowApi';
|
||||
|
||||
let _portTypeCatalog: Record<string, PortSchema> = {};
|
||||
|
||||
export function setPortTypeCatalog(catalog: Record<string, PortSchema>): void {
|
||||
_portTypeCatalog = catalog;
|
||||
}
|
||||
|
||||
function _defaultForType(typeStr: string): unknown {
|
||||
if (typeStr.startsWith('List')) return [];
|
||||
if (typeStr.startsWith('Dict')) return {};
|
||||
if (typeStr === 'bool') return false;
|
||||
if (typeStr === 'int') return 0;
|
||||
if (typeStr === 'str') return '...';
|
||||
if (typeStr === 'Any') return '...';
|
||||
return null;
|
||||
}
|
||||
|
||||
function _buildSchemaPreview(schemaName: string): Record<string, unknown> {
|
||||
const schema = _portTypeCatalog[schemaName];
|
||||
if (!schema || !schema.fields) return {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const field of schema.fields) {
|
||||
result[field.name] = _defaultForType(field.type);
|
||||
}
|
||||
result._success = true;
|
||||
result._error = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function _buildDynamicFormPreview(node: CanvasNode): Record<string, unknown> {
|
||||
const fields = (node.parameters?.fields ?? node.parameters?.formFields) as
|
||||
| Array<{ name?: string; type?: string }>
|
||||
| undefined;
|
||||
if (!Array.isArray(fields)) return { payload: {} };
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (f && typeof f === 'object' && f.name) {
|
||||
payload[f.name] = '';
|
||||
}
|
||||
}
|
||||
return { payload, _success: true, _error: null };
|
||||
}
|
||||
|
||||
/** Build preview for a single node using its outputPorts schema */
|
||||
export function buildNodeOutputPreview(
|
||||
node: CanvasNode,
|
||||
nodeTypeDef?: NodeType
|
||||
): unknown {
|
||||
const outputPorts = nodeTypeDef?.outputPorts;
|
||||
if (!outputPorts) return {};
|
||||
|
||||
const port0 = outputPorts[0];
|
||||
if (!port0) return {};
|
||||
|
||||
if (port0.dynamic && port0.deriveFrom) {
|
||||
if (port0.schema === 'FormPayload') {
|
||||
return _buildDynamicFormPreview(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (port0.schema === 'Transit') {
|
||||
return { _transit: true, _meta: {}, data: {} };
|
||||
}
|
||||
|
||||
return _buildSchemaPreview(port0.schema);
|
||||
}
|
||||
|
||||
/** Build full nodeOutputsPreview map from graph */
|
||||
export function buildNodeOutputsPreview(
|
||||
nodes: CanvasNode[],
|
||||
nodeTypes: NodeType[],
|
||||
nodeOutputsFromRun?: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const typeMap = new Map(nodeTypes.map((nt) => [nt.id, nt]));
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const n of nodes) {
|
||||
const fromRun = nodeOutputsFromRun?.[n.id];
|
||||
if (fromRun !== undefined) {
|
||||
result[n.id] = fromRun;
|
||||
} else {
|
||||
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* Shared types for node config renderers
|
||||
*/
|
||||
|
||||
import type { ApiRequestFunction } from '../../../../api/automation2Api';
|
||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||
|
||||
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
|
||||
export type FormField = {
|
||||
|
|
@ -23,3 +23,12 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
|
|||
|
||||
/** Function type for resolving localized labels */
|
||||
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
||||
|
||||
/** Build an HTML accept attribute from an upload node config's allowedTypes array. */
|
||||
export function getAcceptStringFromConfig(
|
||||
config: Record<string, unknown>
|
||||
): string {
|
||||
const types = config.allowedTypes;
|
||||
if (!Array.isArray(types) || types.length === 0) return '*';
|
||||
return types.join(',');
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import type { NodeConfigRendererProps } from '../configs/types';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
type FormField = {
|
||||
name: string;
|
||||
label: string;
|
||||
|
|
@ -15,17 +17,17 @@ type FormField = {
|
|||
|
||||
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
|
||||
|
||||
function parseFields(params: Record<string, unknown>): FormField[] {
|
||||
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
|
||||
const raw = params.formFields;
|
||||
if (!Array.isArray(raw)) return [{ name: 'field1', label: 'Feld 1', type: 'text' }];
|
||||
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
|
||||
return raw.map((f, i) => {
|
||||
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
||||
const o = f as Record<string, unknown>;
|
||||
const t = String(o.type ?? 'text');
|
||||
const fieldType = String(o.type ?? 'text');
|
||||
const name = String(o.name ?? `field${i + 1}`);
|
||||
const label = String(o.label ?? `Feld ${i + 1}`);
|
||||
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
|
||||
const type = (
|
||||
FORM_FIELD_TYPES.includes(t as (typeof FORM_FIELD_TYPES)[number]) ? t : 'text'
|
||||
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
|
||||
) as FormField['type'];
|
||||
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
|
||||
return {
|
||||
|
|
@ -37,12 +39,13 @@ function parseFields(params: Record<string, unknown>): FormField[] {
|
|||
}
|
||||
return { name, label, type };
|
||||
}
|
||||
return { name: `field${i + 1}`, label: `Feld ${i + 1}`, type: 'text' as const };
|
||||
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
|
||||
});
|
||||
}
|
||||
|
||||
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const fields = useMemo(() => parseFields(params), [params]);
|
||||
const { t } = useLanguage();
|
||||
const fields = useMemo(() => _parseFields(params, t), [params, t]);
|
||||
|
||||
const setFields = (next: FormField[]) => {
|
||||
updateParam('formFields', next);
|
||||
|
|
@ -51,15 +54,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
return (
|
||||
<div className={styles.startNodeDoc}>
|
||||
<p className={styles.startNodeDocIntro}>
|
||||
<strong>Formular-Felder</strong> werden beim Start ausgefüllt und liegen unter{' '}
|
||||
<code>payload.<name></code> in der Start-Ausgabe.
|
||||
<strong>{t('Formular-Felder')}</strong>{' '}
|
||||
{t('werden beim Start ausgefüllt und liegen unter')}{' '}
|
||||
<code>payload.<name></code> {t('in der Start-Ausgabe.')}
|
||||
</p>
|
||||
<div className={styles.formFieldsList}>
|
||||
{fields.map((f, idx) => (
|
||||
<div key={idx} className={styles.formFieldRow}>
|
||||
<input
|
||||
className={styles.startsInput}
|
||||
placeholder="Name (Payload-Key)"
|
||||
placeholder={t('Name (Payload-Key)')}
|
||||
value={f.name}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
|
|
@ -69,7 +73,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
/>
|
||||
<input
|
||||
className={styles.startsInput}
|
||||
placeholder="Beschriftung"
|
||||
placeholder={t('Beschriftung')}
|
||||
value={f.label}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
|
|
@ -82,21 +86,21 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
value={f.type}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
const t = e.target.value as FormField['type'];
|
||||
if (t === 'clickup_status') {
|
||||
const fieldType = e.target.value as FormField['type'];
|
||||
if (fieldType === 'clickup_status') {
|
||||
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
|
||||
} else {
|
||||
next[idx] = { name: f.name, label: f.label, type: t };
|
||||
next[idx] = { name: f.name, label: f.label, type: fieldType };
|
||||
}
|
||||
setFields(next);
|
||||
}}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Zahl</option>
|
||||
<option value="email">E-Mail</option>
|
||||
<option value="date">Datum</option>
|
||||
<option value="boolean">Ja/Nein</option>
|
||||
<option value="clickup_status">ClickUp-Status (Liste)</option>
|
||||
<option value="text">{t('Text')}</option>
|
||||
<option value="number">{t('Zahl')}</option>
|
||||
<option value="email">{t('E-Mail')}</option>
|
||||
<option value="date">{t('Datum')}</option>
|
||||
<option value="boolean">{t('Ja/Nein')}</option>
|
||||
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -111,10 +115,10 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
type="button"
|
||||
className={styles.startsAddBtn}
|
||||
onClick={() =>
|
||||
setFields([...fields, { name: `field${fields.length + 1}`, label: 'Neues Feld', type: 'text' }])
|
||||
setFields([...fields, { name: `field${fields.length + 1}`, label: t('Neues Feld'), type: 'text' }])
|
||||
}
|
||||
>
|
||||
+ Feld
|
||||
{t('+ Feld')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
||||
import type { NodeConfigRendererProps } from '../configs/types';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import {
|
||||
type ScheduleSpec,
|
||||
type ScheduleMode,
|
||||
|
|
@ -16,36 +16,35 @@ import {
|
|||
} from '../runtime/scheduleCron';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
const MODE_OPTIONS: { value: ScheduleMode; title: string; subtitle: string }[] = [
|
||||
{ value: 'daily', title: 'Täglich', subtitle: 'Jeden Tag zur gleichen Zeit' },
|
||||
{ value: 'weekdays', title: 'Werktage', subtitle: 'Montag bis Freitag' },
|
||||
{ value: 'weekly', title: 'Bestimmte Tage', subtitle: 'Wochentage auswählen' },
|
||||
{ value: 'calendar', title: 'Ein anderer Zeitraum', subtitle: 'Monatlich oder jährlich wiederkehrend' },
|
||||
{ value: 'interval', title: 'Intervall', subtitle: 'In regelmäßigen Abständen' },
|
||||
];
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
const MONTH_NAMES_DE = [
|
||||
'Januar',
|
||||
'Februar',
|
||||
'März',
|
||||
'April',
|
||||
'Mai',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'August',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Dezember',
|
||||
];
|
||||
function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
|
||||
return [
|
||||
{ value: 'daily', title: t('Täglich'), subtitle: t('Jeden Tag zur gleichen Zeit') },
|
||||
{ value: 'weekdays', title: t('Werktage'), subtitle: t('Montag bis Freitag') },
|
||||
{ value: 'weekly', title: t('Bestimmte Tage'), subtitle: t('Wochentage auswählen') },
|
||||
{ value: 'calendar', title: t('Ein anderer Zeitraum'), subtitle: t('Monatlich oder jährlich') },
|
||||
{ value: 'interval', title: t('Intervall'), subtitle: t('In regelmäßigen Abständen') },
|
||||
];
|
||||
}
|
||||
|
||||
const INTERVAL_UNITS: { value: IntervalUnit; label: string; title: string }[] = [
|
||||
{ value: 'seconds', label: 'sek', title: 'Sekunden' },
|
||||
{ value: 'minutes', label: 'min', title: 'Minuten' },
|
||||
{ value: 'hours', label: 'h', title: 'Stunden' },
|
||||
{ value: 'days', label: 'd', title: 'Tage' },
|
||||
{ value: 'years', label: 'a', title: 'Jahre' },
|
||||
];
|
||||
function _monthNames(t: (k: string) => string): string[] {
|
||||
return [
|
||||
t('Januar'), t('Februar'), t('März'), t('April'),
|
||||
t('Mai'), t('Juni'), t('Juli'), t('August'),
|
||||
t('September'), t('Oktober'), t('November'), t('Dezember'),
|
||||
];
|
||||
}
|
||||
|
||||
function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] {
|
||||
return [
|
||||
{ value: 'seconds', label: t('Sekunde'), title: t('Sekunden') },
|
||||
{ value: 'minutes', label: t('Minute'), title: t('Minuten') },
|
||||
{ value: 'hours', label: t('Stunde'), title: t('Stunden') },
|
||||
{ value: 'days', label: t('Tag'), title: t('Tage') },
|
||||
{ value: 'years', label: t('Jahr'), title: t('Jahre') },
|
||||
];
|
||||
}
|
||||
|
||||
function timeString(hour: number, minute: number): string {
|
||||
return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`;
|
||||
|
|
@ -77,6 +76,9 @@ function clampInterval(value: number, unit: IntervalUnit): number {
|
|||
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
|
||||
|
||||
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const { t } = useLanguage();
|
||||
const modeOptions = _getModeOptions(t);
|
||||
const intervalUnits = _getIntervalUnits(t);
|
||||
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const specModeRef = useRef(spec.mode);
|
||||
|
|
@ -128,7 +130,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
const onModeCardPointerEvent = (
|
||||
phase: 'pointerdown' | 'click',
|
||||
e: React.PointerEvent | React.MouseEvent,
|
||||
o: (typeof MODE_OPTIONS)[number]
|
||||
o: { value: ScheduleMode; title: string; subtitle: string }
|
||||
) => {
|
||||
const el = e.target as HTMLElement;
|
||||
const cur = e.currentTarget as HTMLElement;
|
||||
|
|
@ -200,13 +202,14 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
return (
|
||||
<div className={styles.schedulePanel}>
|
||||
<p className={styles.startNodeDocIntro}>
|
||||
Legen Sie fest, <strong>wann</strong> dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird
|
||||
unten automatisch erzeugt.
|
||||
{t(
|
||||
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<LayoutGroup>
|
||||
<div className={styles.scheduleModeStack}>
|
||||
{MODE_OPTIONS.map((o) => (
|
||||
{modeOptions.map((o) => (
|
||||
<motion.div
|
||||
key={o.value}
|
||||
data-schedule-mode={o.value}
|
||||
|
|
@ -246,7 +249,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
<div className={styles.scheduleModeConfig}>
|
||||
{o.value === 'daily' && (
|
||||
<label className={styles.scheduleFieldRow}>
|
||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||
<input
|
||||
type="time"
|
||||
step={60}
|
||||
|
|
@ -259,7 +262,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
|
||||
{o.value === 'weekdays' && (
|
||||
<label className={styles.scheduleFieldRow}>
|
||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||
<input
|
||||
type="time"
|
||||
step={60}
|
||||
|
|
@ -273,9 +276,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
{o.value === 'weekly' && (
|
||||
<>
|
||||
<div className={styles.scheduleFieldCol}>
|
||||
<span className={styles.scheduleFieldLabel}>Wochentage</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Wochentage')}</span>
|
||||
<div className={styles.scheduleWeekdayToggles}>
|
||||
{WEEKDAYS_MO_SO.map(({ cronDow, label }) => (
|
||||
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
|
||||
<button
|
||||
key={cronDow}
|
||||
type="button"
|
||||
|
|
@ -284,13 +287,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
}
|
||||
onClick={() => toggleWeekday(cronDow)}
|
||||
>
|
||||
{label}
|
||||
{cronDow === 1 ? t('Mo') : cronDow === 2 ? t('Di') : cronDow === 3 ? t('Mi') : cronDow === 4 ? t('Do') : cronDow === 5 ? t('Fr') : cronDow === 6 ? t('Sa') : t('So')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className={styles.scheduleFieldRow}>
|
||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||
<input
|
||||
type="time"
|
||||
step={60}
|
||||
|
|
@ -314,7 +317,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
}
|
||||
onClick={() => setCalendarPeriod('monthly')}
|
||||
>
|
||||
Monatlich
|
||||
{t('Monatlich')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -325,13 +328,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
}
|
||||
onClick={() => setCalendarPeriod('yearly')}
|
||||
>
|
||||
Jährlich
|
||||
{t('Jährlich')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{spec.calendarPeriod === 'monthly' && (
|
||||
<label className={styles.scheduleFieldRow}>
|
||||
<span className={styles.scheduleFieldLabel}>Monatstag</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Monatstag')}</span>
|
||||
<select
|
||||
className={styles.scheduleSelect}
|
||||
value={spec.monthDay}
|
||||
|
|
@ -349,13 +352,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
{spec.calendarPeriod === 'yearly' && (
|
||||
<div className={styles.scheduleYearlyRow}>
|
||||
<label className={styles.scheduleFieldRowGrow}>
|
||||
<span className={styles.scheduleFieldLabel}>Monat</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Monat')}</span>
|
||||
<select
|
||||
className={styles.scheduleSelect}
|
||||
value={spec.monthIndex}
|
||||
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
|
||||
>
|
||||
{MONTH_NAMES_DE.map((name, i) => (
|
||||
{_monthNames(t).map((name, i) => (
|
||||
<option key={i + 1} value={i + 1}>
|
||||
{name}
|
||||
</option>
|
||||
|
|
@ -363,7 +366,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
</select>
|
||||
</label>
|
||||
<label className={styles.scheduleFieldRowGrow}>
|
||||
<span className={styles.scheduleFieldLabel}>Tag</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Tag')}</span>
|
||||
<select
|
||||
className={styles.scheduleSelect}
|
||||
value={spec.monthDay}
|
||||
|
|
@ -380,7 +383,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
)}
|
||||
|
||||
<label className={styles.scheduleFieldRow}>
|
||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||
<input
|
||||
type="time"
|
||||
step={60}
|
||||
|
|
@ -394,7 +397,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
|
||||
{o.value === 'interval' && (
|
||||
<div className={styles.scheduleIntervalRow}>
|
||||
<span className={styles.scheduleFieldLabel}>Alle</span>
|
||||
<span className={styles.scheduleFieldLabel}>{t('Alle')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
|
|
@ -411,9 +414,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
|||
className={styles.scheduleUnitSelect}
|
||||
value={spec.intervalUnit}
|
||||
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
|
||||
title={INTERVAL_UNITS.find((u) => u.value === spec.intervalUnit)?.title}
|
||||
title={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
|
||||
>
|
||||
{INTERVAL_UNITS.map((u) => (
|
||||
{intervalUnits.map((u) => (
|
||||
<option key={u.value} value={u.value} title={u.title}>
|
||||
{u.label}
|
||||
</option>
|
||||
|
|
@ -4,8 +4,9 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from '../configs/types';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
const SCHEMA_EXAMPLE = `{
|
||||
"trigger": {
|
||||
|
|
@ -22,17 +23,20 @@ const SCHEMA_EXAMPLE = `{
|
|||
}`;
|
||||
|
||||
export const StartNodeConfig: React.FC<NodeConfigRendererProps> = () => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className={styles.startNodeDoc}>
|
||||
<p className={styles.startNodeDocIntro}>
|
||||
Die <strong>Start</strong>-Node liefert beim Ausführen immer dieselbe Struktur. Den <strong>Einstiegstyp</strong>{' '}
|
||||
(manuell, Formular, Zeitplan, …) wählen Sie über das <strong>Zahnrad</strong> oben in der
|
||||
Workflow-Konfiguration.
|
||||
{t(
|
||||
'Die Start-Node liefert beim Ausführen immer dieselbe Struktur. Den Einstiegstyp (manuell, Formular, Zeitplan, …) wählen Sie über das Zahnrad oben in der Workflow-Konfiguration.'
|
||||
)}
|
||||
</p>
|
||||
<p className={styles.startNodeDocSub}>
|
||||
{t('Nachgelagerte Nodes können z. B. auf')}{' '}
|
||||
<code>payload</code> {t('und')} <code>trigger.type</code> {t('zugreifen.')}
|
||||
</p>
|
||||
<p className={styles.startNodeDocSub}>Nachgelagerte Nodes können z. B. auf <code>payload</code> und{' '}
|
||||
<code>trigger.type</code> zugreifen.</p>
|
||||
<div className={styles.startNodeSchema}>
|
||||
<div className={styles.startNodeSchemaTitle}>Ausgabe-Schema</div>
|
||||
<div className={styles.startNodeSchemaTitle}>{t('Ausgabe-Schema')}</div>
|
||||
<pre className={styles.startNodePre}>{SCHEMA_EXAMPLE}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { NodeConfigRendererProps } from '../configs/types';
|
||||
import type { NodeConfigRendererProps } from '../shared/types';
|
||||
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import { isRef, createValue } from '../shared/dataRef';
|
||||
|
|
@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
|
|||
import { operatorsForType } from '../shared/conditionOperators';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export interface SwitchCase {
|
||||
operator: string;
|
||||
value?: string | number | boolean;
|
||||
|
|
@ -31,6 +33,7 @@ function normalizeCase(c: unknown): SwitchCase {
|
|||
}
|
||||
|
||||
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useAutomation2DataFlow();
|
||||
|
||||
const valueParam = params.value;
|
||||
|
|
@ -111,7 +114,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
onChange={(e) => handleCaseValueChange(index, e.target.value)}
|
||||
className={styles.startsInput}
|
||||
>
|
||||
<option value="">— MIME-Type wählen —</option>
|
||||
<option value="">{t('MIME-Typ wählen')}</option>
|
||||
{mimeTypeOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label} ({o.value})
|
||||
|
|
@ -151,9 +154,9 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
}}
|
||||
className={styles.startsInput}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
<option value="true">Ja / wahr</option>
|
||||
<option value="false">Nein / falsch</option>
|
||||
<option value="">{t('Wählen')}</option>
|
||||
<option value="true">{t('Ja (true)')}</option>
|
||||
<option value="false">{t('Nein (false)')}</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
|
@ -163,7 +166,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
className={styles.startsInput}
|
||||
value={valStr}
|
||||
onChange={(e) => handleCaseValueChange(index, e.target.value)}
|
||||
placeholder={isMimeTypeRef ? 'z.B. application/pdf' : `Wert`}
|
||||
placeholder={isMimeTypeRef ? t('z.B. application/pdf') : t('Wert')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -182,28 +185,28 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
return (
|
||||
<div className={styles.ifElseConditionEditor}>
|
||||
<div className={styles.ifElseConditionRow}>
|
||||
<label>Datenquelle</label>
|
||||
<label>{t('Datenquelle')}</label>
|
||||
<RefSourceSelect
|
||||
value={ref}
|
||||
onChange={handleRefChange}
|
||||
placeholder="Feld zum Vergleichen wählen…"
|
||||
placeholder={t('Feld zum Vergleich wählen')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!ref && (
|
||||
<div className={styles.ifElseConditionRow}>
|
||||
<label>Fester Wert (falls keine Referenz)</label>
|
||||
<label>{t('Fester Wert (ohne Referenz)')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(staticValue ?? '')}
|
||||
onChange={(e) => handleStaticValueChange(e.target.value)}
|
||||
placeholder="z.B. CH oder 42"
|
||||
placeholder={t('z. B. CH oder 42')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.ifElseConditionRow}>
|
||||
<label>Fälle (Reihenfolge = Ausgang)</label>
|
||||
<label>{t('Fälle / Reihenfolge / Ausgabe')}</label>
|
||||
<div className={styles.formFieldsList}>
|
||||
{cases.map((c, i) => {
|
||||
const opDef = operators.find((o) => o.value === c.operator) ?? operators[0];
|
||||
|
|
@ -238,7 +241,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
|||
);
|
||||
})}
|
||||
<button type="button" className={styles.startsAddBtn} onClick={addCase}>
|
||||
+ Fall
|
||||
{t('+ Fall')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue