commit
a6241fb296
322 changed files with 53476 additions and 20692 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)
|
||||||
24
src/App.tsx
24
src/App.tsx
|
|
@ -37,11 +37,14 @@ import { DashboardPage } from './pages/Dashboard';
|
||||||
import { SettingsPage } from './pages/Settings';
|
import { SettingsPage } from './pages/Settings';
|
||||||
import { GDPRPage } from './pages/GDPR';
|
import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
|
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
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 { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
|
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||||
|
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,6 +99,8 @@ function App() {
|
||||||
|
|
||||||
{/* System-Seiten (ohne Instanz-Kontext) */}
|
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||||
<Route path="store" element={<StorePage />} />
|
<Route path="store" element={<StorePage />} />
|
||||||
|
<Route path="integrations" element={<IntegrationsOverviewPage />} />
|
||||||
|
<Route path="compliance-audit" element={<ComplianceAuditPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="gdpr" element={<GDPRPage />} />
|
<Route path="gdpr" element={<GDPRPage />} />
|
||||||
|
|
||||||
|
|
@ -114,8 +119,14 @@ function App() {
|
||||||
<Route path="billing">
|
<Route path="billing">
|
||||||
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
||||||
<Route path="transactions" element={<BillingDataView />} />
|
<Route path="transactions" element={<BillingDataView />} />
|
||||||
|
<Route path="admin" element={<BillingAdmin />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* ============================================== */}
|
||||||
|
{/* AUTOMATIONS DASHBOARD */}
|
||||||
|
{/* ============================================== */}
|
||||||
|
<Route path="automations" element={<AutomationsDashboardPage />} />
|
||||||
|
|
||||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||||
|
|
@ -148,6 +159,8 @@ function App() {
|
||||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
<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 */}
|
{/* Automation Feature Views */}
|
||||||
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
||||||
|
|
@ -166,6 +179,9 @@ function App() {
|
||||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||||
|
|
||||||
|
{/* Neutralization Feature Views */}
|
||||||
|
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||||
|
|
||||||
{/* CommCoach Feature Views */}
|
{/* CommCoach Feature Views */}
|
||||||
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
||||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
||||||
|
|
@ -191,13 +207,13 @@ function App() {
|
||||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||||
<Route path="billing">
|
<Route path="billing">
|
||||||
<Route index element={<BillingAdmin />} />
|
<Route index element={<Navigate to="/billing/admin" replace />} />
|
||||||
<Route path="mandates" element={<BillingMandateView />} />
|
<Route path="mandates" element={<BillingMandateView />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
|
||||||
<Route path="automation-logs" element={<AdminAutomationLogsPage />} />
|
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
<Route path="languages" element={null} />
|
||||||
|
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// api.ts
|
// api.ts
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
|
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
|
// Utility function to resolve hostname to IP address
|
||||||
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
|
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
|
||||||
|
|
@ -85,6 +85,13 @@ api.interceptors.request.use(
|
||||||
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
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)
|
// Add multi-tenant context headers from URL (if not already set)
|
||||||
// This ensures Feature-Instance roles are loaded for permission checks
|
// This ensures Feature-Instance roles are loaded for permission checks
|
||||||
const context = getContextFromUrl();
|
const context = getContextFromUrl();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export interface AttributeDefinition {
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
default?: any;
|
default?: any;
|
||||||
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
|
options?: Array<{ value: string | number; label: string }> | string;
|
||||||
validation?: any;
|
validation?: any;
|
||||||
ui?: any;
|
ui?: any;
|
||||||
readonly?: boolean;
|
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: [
|
features: [
|
||||||
{
|
{
|
||||||
code: 'trustee',
|
code: 'trustee',
|
||||||
label: { de: 'Treuhand', en: 'Trustee' },
|
label: 'Treuhand',
|
||||||
icon: 'briefcase',
|
icon: 'briefcase',
|
||||||
instances: [
|
instances: [
|
||||||
{
|
{
|
||||||
|
|
@ -101,7 +101,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'chatworkflow',
|
code: 'chatworkflow',
|
||||||
label: { de: 'Workflow', en: 'Workflow' },
|
label: 'Workflow',
|
||||||
icon: 'play_circle',
|
icon: 'play_circle',
|
||||||
instances: [
|
instances: [
|
||||||
{
|
{
|
||||||
|
|
@ -124,7 +124,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
code: 'trustee',
|
code: 'trustee',
|
||||||
label: { de: 'Treuhand', en: 'Trustee' },
|
label: 'Treuhand',
|
||||||
icon: 'briefcase',
|
icon: 'briefcase',
|
||||||
instances: [
|
instances: [
|
||||||
{
|
{
|
||||||
|
|
@ -234,9 +234,9 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
||||||
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
|
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
return [
|
return [
|
||||||
{ code: 'trustee', label: { de: 'Treuhand', en: 'Trustee' }, icon: 'briefcase', instances: [] },
|
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
||||||
{ code: 'chatworkflow', label: { de: 'Workflow', en: 'Workflow' }, icon: 'play_circle', instances: [] },
|
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
||||||
{ code: 'chatbot', label: { de: 'Chatbot', en: 'Chatbot' }, icon: 'chat', instances: [] },
|
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ export interface FolderInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
|
fileCount?: number;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export interface Prompt {
|
||||||
|
|
||||||
export interface AttributeOption {
|
export interface AttributeOption {
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string | { [key: string]: string };
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttributeDefinition {
|
export interface AttributeDefinition {
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ export interface StoreFeatureInstance {
|
||||||
|
|
||||||
export interface StoreFeature {
|
export interface StoreFeature {
|
||||||
featureCode: string;
|
featureCode: string;
|
||||||
label: Record<string, string>;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: Record<string, string>;
|
description: string;
|
||||||
instances: StoreFeatureInstance[];
|
instances: StoreFeatureInstance[];
|
||||||
canActivate: boolean;
|
canActivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +49,9 @@ export interface SubscriptionInfo {
|
||||||
status: string | null;
|
status: string | null;
|
||||||
maxDataVolumeMB: number | null;
|
maxDataVolumeMB: number | null;
|
||||||
maxFeatureInstances: number | null;
|
maxFeatureInstances: number | null;
|
||||||
|
includedModules: number;
|
||||||
budgetAiCHF: number | null;
|
budgetAiCHF: number | null;
|
||||||
|
budgetAiPerUserCHF: number | null;
|
||||||
currentFeatureInstances: number;
|
currentFeatureInstances: number;
|
||||||
trialEndsAt: string | null;
|
trialEndsAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE';
|
||||||
export interface SubscriptionPlan {
|
export interface SubscriptionPlan {
|
||||||
planKey: string;
|
planKey: string;
|
||||||
selectableByUser: boolean;
|
selectableByUser: boolean;
|
||||||
title: Record<string, string>;
|
title: string;
|
||||||
description: Record<string, string>;
|
description: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
billingPeriod: BillingPeriod;
|
billingPeriod: BillingPeriod;
|
||||||
pricePerUserCHF: number;
|
pricePerUserCHF: number;
|
||||||
|
|
@ -19,8 +19,10 @@ export interface SubscriptionPlan {
|
||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
maxUsers: number | null;
|
maxUsers: number | null;
|
||||||
maxFeatureInstances: number | null;
|
maxFeatureInstances: number | null;
|
||||||
|
includedModules: number;
|
||||||
maxDataVolumeMB?: number | null;
|
maxDataVolumeMB?: number | null;
|
||||||
budgetAiCHF?: number;
|
budgetAiCHF?: number;
|
||||||
|
budgetAiPerUserCHF?: number;
|
||||||
trialDays: number | null;
|
trialDays: number | null;
|
||||||
successorPlanKey: string | null;
|
successorPlanKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -42,11 +44,20 @@ export interface MandateSubscription {
|
||||||
stripeSubscriptionId: string | null;
|
stripeSubscriptionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionUsage {
|
||||||
|
activeUsers: number;
|
||||||
|
activeInstances: number;
|
||||||
|
usedStorageMB: number;
|
||||||
|
maxStorageMB: number | null;
|
||||||
|
storagePercent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubscriptionStatusResponse {
|
export interface SubscriptionStatusResponse {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
subscription: MandateSubscription | null;
|
subscription: MandateSubscription | null;
|
||||||
plan: SubscriptionPlan | null;
|
plan: SubscriptionPlan | null;
|
||||||
scheduled: MandateSubscription | null;
|
scheduled: MandateSubscription | null;
|
||||||
|
usage: SubscriptionUsage | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivatePlanResponse {
|
export interface ActivatePlanResponse {
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,10 @@ export interface TrusteePosition {
|
||||||
|
|
||||||
export interface AccountingConnectorInfo {
|
export interface AccountingConnectorInfo {
|
||||||
connectorType: string;
|
connectorType: string;
|
||||||
label: Record<string, string>;
|
label: string;
|
||||||
configFields: Array<{
|
configFields: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
label: Record<string, string>;
|
label: string;
|
||||||
fieldType: string;
|
fieldType: string;
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
required: 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
|
// ACCOUNTING API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -838,3 +873,17 @@ export async function fetchSyncStatus(
|
||||||
method: 'get'
|
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;
|
description?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
default?: any;
|
default?: any;
|
||||||
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
|
options?: Array<{ value: string | number; label: string }> | string;
|
||||||
validation?: any;
|
validation?: any;
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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';
|
import styles from './AccessRules.module.css';
|
||||||
|
|
||||||
interface AccessLevelSelectProps {
|
interface AccessLevelSelectProps {
|
||||||
|
|
@ -25,6 +26,8 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const accessLevelOptions = _getAccessLevelOptions(t);
|
||||||
const currentColor = getAccessLevelColor(value);
|
const currentColor = getAccessLevelColor(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -42,7 +45,7 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
|
||||||
color: currentColor,
|
color: currentColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ACCESS_LEVEL_OPTIONS.map(option => (
|
{accessLevelOptions.map(option => (
|
||||||
<option
|
<option
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ import { AccessLevelSelect } from './AccessLevelSelect';
|
||||||
import { AccessRulesTable } from './AccessRulesTable';
|
import { AccessRulesTable } from './AccessRulesTable';
|
||||||
import styles from './AccessRules.module.css';
|
import styles from './AccessRules.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -66,6 +68,7 @@ interface RuleCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
|
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const isDataRule = rule.context === 'DATA';
|
const isDataRule = rule.context === 'DATA';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -83,7 +86,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
||||||
<button
|
<button
|
||||||
className={`${styles.iconButton} ${styles.danger}`}
|
className={`${styles.iconButton} ${styles.danger}`}
|
||||||
onClick={() => onDelete(rule.id)}
|
onClick={() => onDelete(rule.id)}
|
||||||
title="Regel löschen"
|
title={t('Regel löschen')}
|
||||||
>
|
>
|
||||||
<FaTrash />
|
<FaTrash />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -94,7 +97,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
||||||
<div className={styles.permissionsGrid}>
|
<div className={styles.permissionsGrid}>
|
||||||
{/* View Toggle */}
|
{/* View Toggle */}
|
||||||
<div className={styles.permissionItem}>
|
<div className={styles.permissionItem}>
|
||||||
<span className={styles.permissionLabel}>View</span>
|
<span className={styles.permissionLabel}>{t('Ansicht')}</span>
|
||||||
<div className={styles.viewToggle}>
|
<div className={styles.viewToggle}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -110,7 +113,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
||||||
{isDataRule ? (
|
{isDataRule ? (
|
||||||
<>
|
<>
|
||||||
<div className={styles.permissionItem}>
|
<div className={styles.permissionItem}>
|
||||||
<span className={styles.permissionLabel}>Read</span>
|
<span className={styles.permissionLabel}>{t('Lesen')}</span>
|
||||||
<AccessLevelSelect
|
<AccessLevelSelect
|
||||||
value={rule.read}
|
value={rule.read}
|
||||||
onChange={(value) => onUpdate(rule.id, { read: value })}
|
onChange={(value) => onUpdate(rule.id, { read: value })}
|
||||||
|
|
@ -119,7 +122,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.permissionItem}>
|
<div className={styles.permissionItem}>
|
||||||
<span className={styles.permissionLabel}>Create</span>
|
<span className={styles.permissionLabel}>{t('Erstellen')}</span>
|
||||||
<AccessLevelSelect
|
<AccessLevelSelect
|
||||||
value={rule.create}
|
value={rule.create}
|
||||||
onChange={(value) => onUpdate(rule.id, { create: value })}
|
onChange={(value) => onUpdate(rule.id, { create: value })}
|
||||||
|
|
@ -128,7 +131,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.permissionItem}>
|
<div className={styles.permissionItem}>
|
||||||
<span className={styles.permissionLabel}>Update</span>
|
<span className={styles.permissionLabel}>{t('Bearbeiten')}</span>
|
||||||
<AccessLevelSelect
|
<AccessLevelSelect
|
||||||
value={rule.update}
|
value={rule.update}
|
||||||
onChange={(value) => onUpdate(rule.id, { update: value })}
|
onChange={(value) => onUpdate(rule.id, { update: value })}
|
||||||
|
|
@ -137,7 +140,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.permissionItem}>
|
<div className={styles.permissionItem}>
|
||||||
<span className={styles.permissionLabel}>Delete</span>
|
<span className={styles.permissionLabel}>{t('Löschen')}</span>
|
||||||
<AccessLevelSelect
|
<AccessLevelSelect
|
||||||
value={rule.delete}
|
value={rule.delete}
|
||||||
onChange={(value) => onUpdate(rule.id, { delete: value })}
|
onChange={(value) => onUpdate(rule.id, { delete: value })}
|
||||||
|
|
@ -167,6 +170,7 @@ interface AddRuleFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
|
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [item, setItem] = useState('');
|
const [item, setItem] = useState('');
|
||||||
const [useCustom, setUseCustom] = useState(false);
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
const [view, setView] = useState(true);
|
const [view, setView] = useState(true);
|
||||||
|
|
@ -210,20 +214,20 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLabel = (obj: CatalogObject): string => {
|
const getLabel = (obj: CatalogObject): string => {
|
||||||
return obj.label.de || obj.label.en || obj.objectKey;
|
return (typeof obj.label === 'string' ? obj.label : '') || obj.objectKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<div className={styles.objectSelectorLabel}>
|
<div className={styles.objectSelectorLabel}>
|
||||||
<label className={styles.formLabel}>Objekt auswählen</label>
|
<label className={styles.formLabel}>{t('select object')}</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.toggleCustomButton}
|
className={styles.toggleCustomButton}
|
||||||
onClick={() => setUseCustom(!useCustom)}
|
onClick={() => setUseCustom(!useCustom)}
|
||||||
>
|
>
|
||||||
{useCustom ? '← Aus Katalog wählen' : 'Freie Eingabe →'}
|
{useCustom ? t('select from catalog') : t('free input')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -242,7 +246,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
||||||
onChange={(e) => setItem(e.target.value)}
|
onChange={(e) => setItem(e.target.value)}
|
||||||
className={styles.formSelect}
|
className={styles.formSelect}
|
||||||
>
|
>
|
||||||
<option value="">-- Global (alle Objekte) --</option>
|
<option value="">{t('global all objects')}</option>
|
||||||
{Object.entries(groupedObjects).map(([feature, objs]) => (
|
{Object.entries(groupedObjects).map(([feature, objs]) => (
|
||||||
<optgroup key={feature} label={feature.toUpperCase()}>
|
<optgroup key={feature} label={feature.toUpperCase()}>
|
||||||
{objs.map(obj => (
|
{objs.map(obj => (
|
||||||
|
|
@ -256,7 +260,9 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className={styles.formHint}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -268,7 +274,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
||||||
onChange={(e) => setView(e.target.checked)}
|
onChange={(e) => setView(e.target.checked)}
|
||||||
style={{ marginRight: '0.5rem' }}
|
style={{ marginRight: '0.5rem' }}
|
||||||
/>
|
/>
|
||||||
Sichtbar (View)
|
{t('Sichtbar (Ansicht)')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -277,17 +283,22 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div className={styles.matrixHeader}>
|
<div className={styles.matrixHeader}>
|
||||||
<div className={styles.matrixLabel}></div>
|
<div className={styles.matrixLabel}></div>
|
||||||
<div className={styles.matrixGroup}>Eigene (m)</div>
|
<div className={styles.matrixGroup}>{t('own')}</div>
|
||||||
<div className={styles.matrixGroup}>Gruppe (g)</div>
|
<div className={styles.matrixGroup}>{t('group')}</div>
|
||||||
<div className={styles.matrixGroup}>Alle (a)</div>
|
<div className={styles.matrixGroup}>{t('Alle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CRUD Rows */}
|
{/* CRUD Rows */}
|
||||||
{(['create', 'read', 'update', 'delete'] as const).map(op => {
|
{(['create', 'read', 'update', 'delete'] as const).map(op => {
|
||||||
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
|
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 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 (
|
return (
|
||||||
<div key={op} className={styles.matrixRow}>
|
<div key={op} className={styles.matrixRow}>
|
||||||
<div className={styles.matrixLabel}>{labels[op]}</div>
|
<div className={styles.matrixLabel}>{labels[op]}</div>
|
||||||
|
|
@ -306,7 +317,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
||||||
setValue(hierarchy[idx - 1] || 'n');
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -318,10 +329,10 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
|
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
|
||||||
Abbrechen
|
{t('Abbrechen')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className={styles.primaryButton}>
|
<button type="submit" className={styles.primaryButton}>
|
||||||
<FaPlus /> Hinzufügen
|
<FaPlus /> {t('Hinzufügen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -351,6 +362,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
onDelete,
|
onDelete,
|
||||||
onAdd,
|
onAdd,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
|
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
|
||||||
|
|
||||||
|
|
@ -369,9 +381,12 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
|
|
||||||
const getEmptyText = () => {
|
const getEmptyText = () => {
|
||||||
switch (context) {
|
switch (context) {
|
||||||
case 'DATA': return 'Keine Daten-Regeln definiert';
|
case 'DATA':
|
||||||
case 'UI': return 'Keine UI-Regeln definiert';
|
return t('Keine Daten-Regeln definiert');
|
||||||
case 'RESOURCE': return 'Keine Ressourcen-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 && (
|
{!readOnly && !showAddForm && (
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<span className={styles.sectionTitle}>
|
<span className={styles.sectionTitle}>
|
||||||
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
|
{rules.length} {rules.length === 1 ? t('Regel') : t('Regeln')}
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
{/* View Toggle */}
|
{/* View Toggle */}
|
||||||
|
|
@ -389,14 +404,14 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
<button
|
<button
|
||||||
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
|
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
|
||||||
onClick={() => setUseTableView(true)}
|
onClick={() => setUseTableView(true)}
|
||||||
title="Tabellenansicht"
|
title={t('Tabellenansicht')}
|
||||||
>
|
>
|
||||||
<FaThList />
|
<FaThList />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
|
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
|
||||||
onClick={() => setUseTableView(false)}
|
onClick={() => setUseTableView(false)}
|
||||||
title="Kartenansicht"
|
title={t('Kartenansicht')}
|
||||||
>
|
>
|
||||||
<FaTh />
|
<FaTh />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -406,7 +421,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
className={styles.addButton}
|
className={styles.addButton}
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
>
|
>
|
||||||
<FaPlus /> Neue Regel
|
<FaPlus /> {t('Neue Regel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -427,7 +442,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
|
||||||
<p className={styles.emptyText}>{getEmptyText()}</p>
|
<p className={styles.emptyText}>{getEmptyText()}</p>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<p className={styles.emptyHint}>
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -465,6 +480,7 @@ interface JsonEditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
|
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [jsonText, setJsonText] = useState('');
|
const [jsonText, setJsonText] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -477,7 +493,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(jsonText);
|
const parsed = JSON.parse(jsonText);
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
throw new Error('JSON muss ein Array sein');
|
throw new Error(t('JSON muss ein Array sein'));
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
onApply(parsed);
|
onApply(parsed);
|
||||||
|
|
@ -497,8 +513,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
|
||||||
/>
|
/>
|
||||||
{error && <div className={styles.jsonError}>{error}</div>}
|
{error && <div className={styles.jsonError}>{error}</div>}
|
||||||
<p className={styles.jsonHint}>
|
<p className={styles.jsonHint}>
|
||||||
Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON.
|
{t(
|
||||||
Änderungen werden erst nach Klick auf "Anwenden" übernommen.
|
'Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON. Änderungen werden erst nach Klick auf „Anwenden“ übernommen.'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
|
|
@ -508,7 +525,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
disabled={!!error}
|
disabled={!!error}
|
||||||
>
|
>
|
||||||
JSON anwenden
|
{t('JSON anwenden')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -531,6 +548,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
featureCode,
|
featureCode,
|
||||||
}) => {
|
}) => {
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
const {
|
const {
|
||||||
rules,
|
rules,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -602,7 +620,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
onSave?.();
|
onSave?.();
|
||||||
} else {
|
} 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
|
// Render tabs
|
||||||
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
|
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: '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 },
|
{ 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.accessRulesEditor}>
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>Lade Berechtigungen...</span>
|
<span>{t('loading permissions')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -650,7 +668,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||||
<div className={styles.editorHeader}>
|
<div className={styles.editorHeader}>
|
||||||
<h3 className={styles.editorTitle}>
|
<h3 className={styles.editorTitle}>
|
||||||
Berechtigungen{roleName ? `: ${roleName}` : ''}
|
Berechtigungen{roleName ? `: ${roleName}` : ''}
|
||||||
{isTemplate && <span className={styles.templateBadge}>Template</span>}
|
{isTemplate && <span className={styles.templateBadge}>{t('Vorlage')}</span>}
|
||||||
</h3>
|
</h3>
|
||||||
{!readOnly && hasChanges && (
|
{!readOnly && hasChanges && (
|
||||||
<div className={styles.headerActions}>
|
<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 { type AccessRule, type RuleContext, type AccessLevel } from '../../hooks/useAccessRules';
|
||||||
import styles from './AccessRules.module.css';
|
import styles from './AccessRules.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -74,6 +76,9 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
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 = (
|
const handleLevelToggle = (
|
||||||
field: 'read' | 'create' | 'update' | 'delete',
|
field: 'read' | 'create' | 'update' | 'delete',
|
||||||
targetLevel: 'm' | 'g' | 'a',
|
targetLevel: 'm' | 'g' | 'a',
|
||||||
|
|
@ -109,7 +114,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||||
checked={rule.view}
|
checked={rule.view}
|
||||||
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
|
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title="Sichtbar"
|
title={t('Sichtbar')}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
@ -124,7 +129,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||||
checked={hasLevel(rule[op] as AccessLevel, 'm')}
|
checked={hasLevel(rule[op] as AccessLevel, 'm')}
|
||||||
onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)}
|
onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Eigene`}
|
title={`${opTitle(op)} - ${t('Eigene')}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
@ -137,7 +142,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||||
checked={hasLevel(rule[op] as AccessLevel, 'g')}
|
checked={hasLevel(rule[op] as AccessLevel, 'g')}
|
||||||
onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)}
|
onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Gruppe`}
|
title={`${opTitle(op)} - ${t('Gruppe')}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
@ -150,7 +155,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||||
checked={hasLevel(rule[op] as AccessLevel, 'a')}
|
checked={hasLevel(rule[op] as AccessLevel, 'a')}
|
||||||
onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)}
|
onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Alle`}
|
title={`${opTitle(op)} - ${t('Alle')}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
@ -163,7 +168,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
|
||||||
<button
|
<button
|
||||||
className={`${styles.iconButton} ${styles.danger}`}
|
className={`${styles.iconButton} ${styles.danger}`}
|
||||||
onClick={() => onDelete(rule.id)}
|
onClick={() => onDelete(rule.id)}
|
||||||
title="Regel löschen"
|
title={t('Regel löschen')}
|
||||||
>
|
>
|
||||||
<FaTrash />
|
<FaTrash />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -184,6 +189,7 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const isDataContext = context === 'DATA';
|
const isDataContext = context === 'DATA';
|
||||||
|
|
||||||
if (rules.length === 0) {
|
if (rules.length === 0) {
|
||||||
|
|
@ -195,13 +201,13 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
||||||
<table className={styles.accessRulesTable}>
|
<table className={styles.accessRulesTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={styles.colObject}>Objekt (Dot-Notation)</th>
|
<th className={styles.colObject}>{t('object dot notation')}</th>
|
||||||
<th className={styles.colView}>View</th>
|
<th className={styles.colView}>{t('Ansicht')}</th>
|
||||||
{isDataContext && (
|
{isDataContext && (
|
||||||
<>
|
<>
|
||||||
<th className={styles.colGroupHeader} colSpan={4}>Eigene (m)</th>
|
<th className={styles.colGroupHeader} colSpan={4}>{t('own')}</th>
|
||||||
<th className={styles.colGroupHeader} colSpan={4}>Gruppe (g)</th>
|
<th className={styles.colGroupHeader} colSpan={4}>{t('group')}</th>
|
||||||
<th className={styles.colGroupHeader} colSpan={4}>Alle (a)</th>
|
<th className={styles.colGroupHeader} colSpan={4}>{t('Alle')}</th>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<th className={styles.colActions}></th>
|
<th className={styles.colActions}></th>
|
||||||
|
|
@ -210,18 +216,18 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
|
||||||
<tr className={styles.subHeader}>
|
<tr className={styles.subHeader}>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th title="Create">C</th>
|
<th title={t('Erstellen')}>C</th>
|
||||||
<th title="Read">R</th>
|
<th title={t('Lesen')}>R</th>
|
||||||
<th title="Update">U</th>
|
<th title={t('Bearbeiten')}>U</th>
|
||||||
<th title="Delete">D</th>
|
<th title={t('Löschen')}>D</th>
|
||||||
<th title="Create">C</th>
|
<th title={t('Erstellen')}>C</th>
|
||||||
<th title="Read">R</th>
|
<th title={t('Lesen')}>R</th>
|
||||||
<th title="Update">U</th>
|
<th title={t('Bearbeiten')}>U</th>
|
||||||
<th title="Delete">D</th>
|
<th title={t('Löschen')}>D</th>
|
||||||
<th title="Create">C</th>
|
<th title={t('Erstellen')}>C</th>
|
||||||
<th title="Read">R</th>
|
<th title={t('Lesen')}>R</th>
|
||||||
<th title="Update">U</th>
|
<th title={t('Bearbeiten')}>U</th>
|
||||||
<th title="Delete">D</th>
|
<th title={t('Löschen')}>D</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</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) {
|
if (isOpen && fileId) {
|
||||||
// Check if we have valid data
|
// Check if we have valid data
|
||||||
if (!fileId || fileId === 'undefined' || fileId === 'null') {
|
if (!fileId || fileId === 'undefined' || fileId === 'null') {
|
||||||
setError('Invalid file ID');
|
setError(t('Ungültige Datei-ID'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!fileName || fileName === 'Unknown Item') {
|
if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') {
|
||||||
setError('File name not available');
|
setError(t('Dateiname nicht verfügbar'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadPreview();
|
loadPreview();
|
||||||
|
|
@ -77,7 +77,7 @@ export function ContentPreview({
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
}, [isOpen, fileId, fileName]);
|
}, [isOpen, fileId, fileName, t]);
|
||||||
|
|
||||||
|
|
||||||
const loadPreview = async () => {
|
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
|
// If it's text content but MIME type says PDF, we'll handle it in renderPreview
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to load preview');
|
setError(result.error || t('Vorschau konnte nicht geladen werden.'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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[] = [
|
const actions: PopupAction[] = [
|
||||||
// Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs
|
// 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) ? [{
|
...(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 />,
|
icon: copySuccess ? '✓' : <IoIosCopy />,
|
||||||
onClick: handleCopyContent,
|
onClick: handleCopyContent,
|
||||||
disabled: !previewContent && !previewUrl,
|
disabled: !previewContent && !previewUrl,
|
||||||
|
|
@ -168,7 +168,7 @@ export function ContentPreview({
|
||||||
previewUrl={undefined}
|
previewUrl={undefined}
|
||||||
previewContent={previewContent}
|
previewContent={previewContent}
|
||||||
fileName={fileName}
|
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 (
|
return (
|
||||||
<div className={styles.jsonContainer}>
|
<div className={styles.jsonContainer}>
|
||||||
<div className={styles.jsonHeader}>
|
<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}>
|
<div className={styles.jsonHeaderRight}>
|
||||||
<span className={styles.jsonSize}>Raw content</span>
|
<span className={styles.jsonSize}>{t('Rohinhalt')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre className={styles.jsonPreview}>
|
<pre className={styles.jsonPreview}>
|
||||||
<code className={styles.jsonCode}>
|
<code className={styles.jsonCode}>
|
||||||
{previewContent || 'No content available'}
|
{previewContent || t('Kein Inhalt verfügbar')}
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -219,7 +219,7 @@ export function ContentPreview({
|
||||||
<ImageRenderer
|
<ImageRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
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
|
<HtmlRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
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}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
mimeType={mimeType}
|
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}
|
previewUrl={previewUrl}
|
||||||
previewContent={previewContent || undefined}
|
previewContent={previewContent || undefined}
|
||||||
fileName={fileName}
|
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
|
<HtmlRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
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}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
mimeType={mimeType}
|
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
|
<Popup
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||||
size="fullscreen"
|
size="fullscreen"
|
||||||
className={styles.contentPreviewPopup}
|
className={styles.contentPreviewPopup}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export function UrlContentPreview({
|
||||||
}
|
}
|
||||||
// If PDF.js also fails, show error
|
// If PDF.js also fails, show error
|
||||||
setIsLoading(false);
|
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);
|
setShowPdfAnyway(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -96,7 +96,9 @@ export function UrlContentPreview({
|
||||||
|
|
||||||
const warningTimeout = setTimeout(() => {
|
const warningTimeout = setTimeout(() => {
|
||||||
if (isLoading && !hasLoaded) {
|
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
|
// Don't set isLoading to false - let it continue
|
||||||
}
|
}
|
||||||
}, WARNING_TIMEOUT);
|
}, WARNING_TIMEOUT);
|
||||||
|
|
@ -107,11 +109,11 @@ export function UrlContentPreview({
|
||||||
console.log('PDF loading timeout, switching to PDF.js fallback');
|
console.log('PDF loading timeout, switching to PDF.js fallback');
|
||||||
setUsePdfJs(true);
|
setUsePdfJs(true);
|
||||||
setIsLoading(true); // Restart loading with PDF.js
|
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) {
|
} else if (isLoading && !hasLoaded && usePdfJs) {
|
||||||
// PDF.js also failed, show error
|
// PDF.js also failed, show error
|
||||||
setShowPdfAnyway(true);
|
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);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, QUICK_TIMEOUT);
|
}, QUICK_TIMEOUT);
|
||||||
|
|
@ -121,7 +123,7 @@ export function UrlContentPreview({
|
||||||
clearTimeout(errorTimeout);
|
clearTimeout(errorTimeout);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isOpen, isLoading, hasLoaded, usePdfJs]);
|
}, [isOpen, isLoading, hasLoaded, usePdfJs, t]);
|
||||||
|
|
||||||
// Validate URL
|
// Validate URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -129,7 +131,7 @@ export function UrlContentPreview({
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
new URL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('Invalid URL');
|
setError(t('Ungültige URL'));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +186,7 @@ export function UrlContentPreview({
|
||||||
padding: '0.5rem 1rem'
|
padding: '0.5rem 1rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
In neuem Tab öffnen
|
{t('In neuem Tab öffnen')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|
@ -195,7 +197,7 @@ export function UrlContentPreview({
|
||||||
padding: '0.5rem 1rem'
|
padding: '0.5rem 1rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download
|
{t('Herunterladen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,7 +231,7 @@ export function UrlContentPreview({
|
||||||
}}
|
}}
|
||||||
className={styles.retryButton}
|
className={styles.retryButton}
|
||||||
>
|
>
|
||||||
{t('common.retry', 'Retry')}
|
{t('Wiederholen')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenInNewTab}
|
onClick={handleOpenInNewTab}
|
||||||
|
|
@ -241,7 +243,7 @@ export function UrlContentPreview({
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
In neuem Tab öffnen
|
{t('In neuem Tab öffnen')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|
@ -253,7 +255,7 @@ export function UrlContentPreview({
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download File
|
{t('Datei herunterladen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -284,7 +286,7 @@ export function UrlContentPreview({
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
In neuem Tab öffnen
|
{t('In neuem Tab öffnen')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|
@ -296,7 +298,7 @@ export function UrlContentPreview({
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download
|
{t('Herunterladen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,9 +316,9 @@ export function UrlContentPreview({
|
||||||
<div className={styles.unsupportedContainer}>
|
<div className={styles.unsupportedContainer}>
|
||||||
<div className={styles.unsupportedIcon}>📄</div>
|
<div className={styles.unsupportedIcon}>📄</div>
|
||||||
<div className={styles.fileName}>{fileName}</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}>
|
<button onClick={handleDownload} className={styles.retryButton}>
|
||||||
Download File
|
{t('Datei herunterladen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -329,7 +331,7 @@ export function UrlContentPreview({
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||||
size="fullscreen"
|
size="fullscreen"
|
||||||
className={styles.contentPreviewPopup}
|
className={styles.contentPreviewPopup}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
className={styles.retryButton}
|
className={styles.retryButton}
|
||||||
>
|
>
|
||||||
{t('common.retry', 'Retry')}
|
{t('Wiederholen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
||||||
<button
|
<button
|
||||||
className={styles.collapseButton}
|
className={styles.collapseButton}
|
||||||
onClick={() => toggleCollapse(rowPath)}
|
onClick={() => toggleCollapse(rowPath)}
|
||||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
title={isCollapsed ? t('Aufklappen') : t('Einklappen')}
|
||||||
>
|
>
|
||||||
{isCollapsed ? '▶' : '▼'}
|
{isCollapsed ? '▶' : '▼'}
|
||||||
</button>
|
</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] ?
|
typeof data.values[index] === 'object' && data.values[index] !== null && 'keys' in data.values[index] ?
|
||||||
renderTable(data.values[index], level + 1, rowPath) :
|
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>
|
</div>
|
||||||
|
|
@ -471,7 +471,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
||||||
<div className={styles.jsonContainer}>
|
<div className={styles.jsonContainer}>
|
||||||
<div className={styles.jsonHeader}>
|
<div className={styles.jsonHeader}>
|
||||||
<div className={styles.jsonHeaderRight}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{renderTable(preprocessedData, 0, 'root')}
|
{renderTable(preprocessedData, 0, 'root')}
|
||||||
|
|
@ -479,7 +479,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
||||||
);
|
);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
const rawData = {
|
const rawData = {
|
||||||
keys: ['Raw Content'],
|
keys: [t('Rohinhalt')],
|
||||||
values: [previewContent],
|
values: [previewContent],
|
||||||
types: ['string'],
|
types: ['string'],
|
||||||
isNested: [false]
|
isNested: [false]
|
||||||
|
|
@ -488,14 +488,14 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.jsonContainer}>
|
<div className={styles.jsonContainer}>
|
||||||
<div className={styles.jsonHeader}>
|
<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}>
|
<div className={styles.jsonHeaderRight}>
|
||||||
<button
|
<button
|
||||||
className={styles.copyButton}
|
className={styles.copyButton}
|
||||||
onClick={handleCopyJson}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export function LoadingRenderer() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner}></div>
|
<div className={styles.spinner}></div>
|
||||||
<p>{t('files.preview.loading', 'Loading preview...')}</p>
|
<p>{t('Vorschau wird geladen...')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
// Set worker source for PDF.js
|
// Set worker source for PDF.js
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Try to use local worker first, fallback to CDN
|
// Try to use local worker first, fallback to CDN
|
||||||
|
|
@ -24,7 +26,9 @@ interface PdfJsRendererProps {
|
||||||
onLoad?: () => void;
|
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -62,7 +66,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading PDF with PDF.js:', err);
|
console.error('Error loading PDF with PDF.js:', err);
|
||||||
if (isMounted) {
|
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);
|
setIsLoading(false);
|
||||||
onError();
|
onError();
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +116,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error rendering PDF page:', err);
|
console.error('Error rendering PDF page:', err);
|
||||||
if (isMounted) {
|
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 (
|
return (
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<div className={styles.errorIcon}>⚠️</div>
|
<div className={styles.errorIcon}>⚠️</div>
|
||||||
<p>Fehler beim Laden der PDF: {error}</p>
|
<p>
|
||||||
|
{t('Fehler beim Laden der PDF:')} {error}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +143,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
|
||||||
return (
|
return (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner}></div>
|
<div className={styles.spinner}></div>
|
||||||
<p>PDF wird geladen...</p>
|
<p>{t('PDF wird geladen')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: P
|
||||||
<div className={styles.warningMessage}>
|
<div className={styles.warningMessage}>
|
||||||
<span className={styles.warningIcon}><IoIosWarning /></span>
|
<span className={styles.warningIcon}><IoIosWarning /></span>
|
||||||
<span className={styles.warningText}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
// Updated to handle both previewUrl and previewContent
|
// Updated to handle both previewUrl and previewContent
|
||||||
|
|
||||||
interface TextRendererProps {
|
interface TextRendererProps {
|
||||||
|
|
@ -10,13 +12,15 @@ interface TextRendererProps {
|
||||||
onError: () => void;
|
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 we have previewContent directly, display it as text
|
||||||
if (previewContent && !previewUrl) {
|
if (previewContent && !previewUrl) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.textContainer}>
|
<div className={styles.textContainer}>
|
||||||
<div className={styles.textHeader}>
|
<div className={styles.textHeader}>
|
||||||
<span className={styles.textTitle}>Text Preview</span>
|
<span className={styles.textTitle}>{t('Textvorschau')}</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className={styles.textPreview}>
|
<pre className={styles.textPreview}>
|
||||||
<code className={styles.textCode}>
|
<code className={styles.textCode}>
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,14 @@ export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendere
|
||||||
return (
|
return (
|
||||||
<div className={styles.unsupportedContainer}>
|
<div className={styles.unsupportedContainer}>
|
||||||
<div className={styles.unsupportedIcon}>📄</div>
|
<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>
|
<p className={styles.fileName}>{fileName}</p>
|
||||||
<a
|
<a
|
||||||
href={previewUrl}
|
href={previewUrl}
|
||||||
download={fileName}
|
download={fileName}
|
||||||
className={styles.downloadButton}
|
className={styles.downloadButton}
|
||||||
>
|
>
|
||||||
{t('files.action.download', 'Download')}
|
{t('Herunterladen')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
|
* 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 React, { createContext, useContext, useMemo } from 'react';
|
||||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||||
import type { NodeType } from '../../../api/automation2Api';
|
import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||||
|
|
||||||
export interface Automation2DataFlowContextValue {
|
export interface Automation2DataFlowContextValue {
|
||||||
currentNodeId: string;
|
currentNodeId: string;
|
||||||
|
|
@ -14,6 +15,8 @@ export interface Automation2DataFlowContextValue {
|
||||||
nodeOutputsPreview: Record<string, unknown>;
|
nodeOutputsPreview: Record<string, unknown>;
|
||||||
nodeTypes: NodeType[];
|
nodeTypes: NodeType[];
|
||||||
language: string;
|
language: string;
|
||||||
|
portTypeCatalog: Record<string, PortSchema>;
|
||||||
|
systemVariables: Record<string, SystemVariable>;
|
||||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||||
getAvailableSourceIds: () => string[];
|
getAvailableSourceIds: () => string[];
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +34,8 @@ interface Automation2DataFlowProviderProps {
|
||||||
nodeOutputsPreview: Record<string, unknown>;
|
nodeOutputsPreview: Record<string, unknown>;
|
||||||
nodeTypes: NodeType[];
|
nodeTypes: NodeType[];
|
||||||
language: string;
|
language: string;
|
||||||
|
portTypeCatalog?: Record<string, PortSchema>;
|
||||||
|
systemVariables?: Record<string, SystemVariable>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +46,8 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
nodeOutputsPreview,
|
nodeOutputsPreview,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
language,
|
language,
|
||||||
|
portTypeCatalog = {},
|
||||||
|
systemVariables = {},
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||||
|
|
@ -52,11 +59,13 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
nodeOutputsPreview,
|
nodeOutputsPreview,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
language,
|
language,
|
||||||
|
portTypeCatalog,
|
||||||
|
systemVariables,
|
||||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||||
n.title ?? n.label ?? n.type ?? n.id,
|
n.title ?? n.label ?? n.type ?? n.id,
|
||||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||||
};
|
};
|
||||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language]);
|
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Automation2DataFlowContext.Provider value={value}>
|
<Automation2DataFlowContext.Provider value={value}>
|
||||||
|
|
@ -14,13 +14,29 @@
|
||||||
SIDEBAR - Node List
|
SIDEBAR - Node List
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
|
|
||||||
.sidebar {
|
.resizeDivider {
|
||||||
flex-shrink: 0;
|
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;
|
width: 280px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-secondary, #f8f9fa);
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
border-left: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +124,7 @@
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeItem:hover {
|
.nodeItem:hover {
|
||||||
|
|
@ -151,6 +168,29 @@
|
||||||
text-overflow: ellipsis;
|
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 */
|
||||||
.loading,
|
.loading,
|
||||||
.error {
|
.error {
|
||||||
|
|
@ -318,6 +358,19 @@
|
||||||
box-shadow: 0 0 0 2px var(--primary-color, #007bff);
|
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 {
|
.canvasNodeContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -360,6 +413,39 @@
|
||||||
text-decoration: underline;
|
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 {
|
.canvasNodeInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.15rem 0.25rem;
|
padding: 0.15rem 0.25rem;
|
||||||
|
|
@ -446,6 +532,13 @@
|
||||||
color: var(--text-tertiary, #999);
|
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 {
|
.nodeConfigPanel label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
@ -713,13 +806,13 @@
|
||||||
.scheduleModeBlock {
|
.scheduleModeBlock {
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Ausgewählte Karte (orange) + Text auf „An“-Chips im erweiterten Bereich */
|
/* Ausgewählte Karte (orange) + Text auf „An“-Chips im erweiterten Bereich */
|
||||||
--schedule-active: var(--schedule-mode-active, var(--color-secondary, #f25843));
|
--schedule-active: var(--schedule-mode-active, var(--color-secondary));
|
||||||
--schedule-active-border: var(--schedule-mode-active-border, var(--color-text, #3a3a3a));
|
--schedule-active-border: var(--schedule-mode-active-border, var(--color-text));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border-radius: 25px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--color-text, #ddd);
|
border: 1px solid var(--color-border, #E2E8F0);
|
||||||
background-color: var(--bg-primary, #fff);
|
background-color: var(--bg-primary, #fff);
|
||||||
color: var(--color-text, #222);
|
color: var(--color-text, #222);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -1451,3 +1544,33 @@
|
||||||
border-color: var(--primary-color, #007bff);
|
border-color: var(--primary-color, #007bff);
|
||||||
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 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 styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export interface CanvasNode {
|
export interface CanvasNode {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -19,6 +21,8 @@ export interface CanvasNode {
|
||||||
inputs: number;
|
inputs: number;
|
||||||
outputs: number;
|
outputs: number;
|
||||||
parameters?: Record<string, unknown>;
|
parameters?: Record<string, unknown>;
|
||||||
|
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||||
|
outputPorts?: Array<{ name: string; schema: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasConnection {
|
export interface CanvasConnection {
|
||||||
|
|
@ -34,6 +38,30 @@ const NODE_HEIGHT = 72;
|
||||||
const HANDLE_SIZE = 12;
|
const HANDLE_SIZE = 12;
|
||||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
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 {
|
interface FlowCanvasProps {
|
||||||
nodes: CanvasNode[];
|
nodes: CanvasNode[];
|
||||||
connections: CanvasConnection[];
|
connections: CanvasConnection[];
|
||||||
|
|
@ -44,10 +72,17 @@ interface FlowCanvasProps {
|
||||||
getLabel: (node: CanvasNode) => string;
|
getLabel: (node: CanvasNode) => string;
|
||||||
getCategoryIcon: (category: string) => React.ReactNode;
|
getCategoryIcon: (category: string) => React.ReactNode;
|
||||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||||
|
highlightedNodeIds?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
const HIGHLIGHT_COLORS: Record<string, string> = {
|
||||||
nodes,
|
running: '#f0ad4e',
|
||||||
|
completed: '#28a745',
|
||||||
|
failed: '#dc3545',
|
||||||
|
skipped: '#6c757d',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
connections,
|
connections,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
onNodesChange,
|
onNodesChange,
|
||||||
|
|
@ -56,11 +91,14 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
getLabel,
|
getLabel,
|
||||||
getCategoryIcon,
|
getCategoryIcon,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
highlightedNodeIds,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
||||||
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
|
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
|
||||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
|
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
|
||||||
|
const [connectionWarnings, setConnectionWarnings] = useState<Record<string, boolean>>({});
|
||||||
const [selectionBox, setSelectionBox] = useState<{
|
const [selectionBox, setSelectionBox] = useState<{
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
|
|
@ -236,6 +274,18 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
targetId: targetNodeId,
|
targetId: targetNodeId,
|
||||||
targetHandle: targetHandleIndex,
|
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]);
|
onConnectionsChange([...connections, newConn]);
|
||||||
setConnectingFrom(null);
|
setConnectingFrom(null);
|
||||||
setDragPos(null);
|
setDragPos(null);
|
||||||
|
|
@ -510,17 +560,30 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
>
|
>
|
||||||
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
|
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
|
||||||
<div className={styles.connectionHint}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{connectingFrom && !selectedConnectionId && (
|
{connectingFrom && !selectedConnectionId && (
|
||||||
<div className={styles.connectionHint}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedConnectionId && (
|
{selectedConnectionId && (
|
||||||
<div className={styles.connectionHint}>
|
<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>
|
||||||
)}
|
)}
|
||||||
<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)" />
|
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" />
|
||||||
</marker>
|
</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>
|
</defs>
|
||||||
{connections.map((c) => {
|
{connections.map((c) => {
|
||||||
const srcNode = nodes.find((n) => n.id === c.sourceId);
|
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 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 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 isSelected = selectedConnectionId === c.id;
|
||||||
|
const isWarning = connectionWarnings[c.id];
|
||||||
|
const strokeColor = isSelected
|
||||||
|
? 'var(--primary-color, #007bff)'
|
||||||
|
: isWarning
|
||||||
|
? '#FF9800'
|
||||||
|
: 'var(--text-secondary, #666)';
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
|
@ -576,7 +655,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={-1}
|
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
|
<path
|
||||||
d={pathD}
|
d={pathD}
|
||||||
|
|
@ -588,11 +667,15 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
<path
|
<path
|
||||||
d={pathD}
|
d={pathD}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={isSelected ? 'var(--primary-color, #007bff)' : 'var(--text-secondary, #666)'}
|
stroke={strokeColor}
|
||||||
strokeWidth={isSelected ? 3 : 2}
|
strokeWidth={isSelected ? 3 : 2}
|
||||||
|
strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
|
||||||
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
|
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
|
{isWarning && !isSelected && (
|
||||||
|
<title>{t('Typeninkompatibilität: Ausgabetyp')}</title>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -621,18 +704,21 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
const isSelected = selectedNodeIds.has(node.id);
|
const isSelected = selectedNodeIds.has(node.id);
|
||||||
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||||
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||||||
|
const hlStatus = highlightedNodeIds?.[node.id];
|
||||||
|
const hlColor = hlStatus ? HIGHLIGHT_COLORS[hlStatus] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''}`}
|
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''} ${hlStatus ? styles.canvasNodeHighlighted : ''}`}
|
||||||
style={{
|
style={{
|
||||||
left: node.x,
|
left: node.x,
|
||||||
top: node.y,
|
top: node.y,
|
||||||
width: NODE_WIDTH,
|
width: NODE_WIDTH,
|
||||||
height: NODE_HEIGHT,
|
height: NODE_HEIGHT,
|
||||||
borderColor: color,
|
borderColor: hlColor || color,
|
||||||
backgroundColor: `${color}15`,
|
backgroundColor: hlColor ? `${hlColor}20` : `${color}15`,
|
||||||
|
boxShadow: hlStatus === 'running' ? `0 0 12px ${hlColor}80` : undefined,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
|
|
@ -687,8 +773,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
outputLabel ??
|
outputLabel ??
|
||||||
(selectedConnectionId && !isOutput
|
(selectedConnectionId && !isOutput
|
||||||
? used
|
? used
|
||||||
? 'Aktuelles Ziel – klicken zum Abwählen'
|
? t('Aktuelles Ziel klicken, um abzuwählen')
|
||||||
: 'Klicken zum Umleiten'
|
: t('Klicken zum Umleiten')
|
||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -743,7 +829,13 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{node.comment && (
|
||||||
|
<span className={styles.canvasNodeComment}>{node.comment}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{node.comment && (
|
||||||
|
<div className={styles.canvasNodeCommentTooltip}>{node.comment}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -761,7 +853,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||||
)}
|
)}
|
||||||
{nodes.length === 0 && (
|
{nodes.length === 0 && (
|
||||||
<div className={styles.canvasPlaceholder}>
|
<div className={styles.canvasPlaceholder}>
|
||||||
<p>Nodes aus der Liste links hierher ziehen.</p>
|
<p>{t('Knoten aus der Liste links ziehen')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
|
* NodeConfigPanel - Generic parameter renderer for all node types.
|
||||||
* Delegates to config components from nodes/configs.
|
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import type { CanvasNode } from './FlowCanvas';
|
import type { CanvasNode } from './FlowCanvas';
|
||||||
import type { NodeType } from '../../../api/automation2Api';
|
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
|
||||||
import type { ApiRequestFunction } from '../../../api/automation2Api';
|
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
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 styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface NodeConfigPanelProps {
|
interface NodeConfigPanelProps {
|
||||||
node: CanvasNode | null;
|
node: CanvasNode | null;
|
||||||
nodeType: NodeType | undefined;
|
nodeType: NodeType | undefined;
|
||||||
|
|
@ -22,18 +24,16 @@ interface NodeConfigPanelProps {
|
||||||
request?: ApiRequestFunction;
|
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,
|
nodeType,
|
||||||
language,
|
language,
|
||||||
onParametersChange,
|
onParametersChange,
|
||||||
onMergeNodeParameters,
|
onMergeNodeParameters: _onMergeNodeParameters,
|
||||||
onNodeUpdate,
|
onNodeUpdate,
|
||||||
instanceId,
|
instanceId,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
const nodeIdRef = useRef<string | undefined>(undefined);
|
const nodeIdRef = useRef<string | undefined>(undefined);
|
||||||
nodeIdRef.current = node?.id;
|
nodeIdRef.current = node?.id;
|
||||||
|
|
@ -52,7 +52,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
}, [node?.id]);
|
}, [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(
|
const updateParam = useCallback(
|
||||||
(key: string, value: unknown) => {
|
(key: string, value: unknown) => {
|
||||||
setParams((prev) => {
|
setParams((prev) => {
|
||||||
|
|
@ -73,48 +72,51 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||||
[onParametersChange]
|
[onParametersChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
|
if (!node || !nodeType) return null;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTrigger = node.type.startsWith('trigger.');
|
const isTrigger = node.type.startsWith('trigger.');
|
||||||
const showNameField = onNodeUpdate && !isTrigger;
|
const showNameField = onNodeUpdate && !isTrigger;
|
||||||
|
const parameters = nodeType.parameters || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.nodeConfigPanel}>
|
<div className={styles.nodeConfigPanel}>
|
||||||
{showNameField && (
|
{showNameField && (
|
||||||
<div className={styles.nodeConfigNameRow}>
|
<div className={styles.nodeConfigNameRow}>
|
||||||
<label htmlFor="node-config-name">Bezeichnung</label>
|
<label htmlFor="node-config-name">{t('Bezeichnung')}</label>
|
||||||
<input
|
<input
|
||||||
id="node-config-name"
|
id="node-config-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={node.title ?? ''}
|
value={node.title ?? ''}
|
||||||
onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })}
|
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}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||||
<ConfigRenderer
|
{nodeType?.description && (
|
||||||
params={params}
|
<p className={styles.nodeConfigDescription}>
|
||||||
updateParam={updateParam}
|
{getLabel(nodeType.description, language)}
|
||||||
instanceId={instanceId}
|
</p>
|
||||||
request={request}
|
)}
|
||||||
nodeType={node.type}
|
{parameters.map((param: NodeTypeParameter) => {
|
||||||
mergeNodeParameters={onMergeNodeParameters}
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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 React, { useMemo } from 'react';
|
||||||
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
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 { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { NodeListItem } from './NodeListItem';
|
import { NodeListItem } from './NodeListItem';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface NodeSidebarProps {
|
interface NodeSidebarProps {
|
||||||
nodeTypes: NodeType[];
|
nodeTypes: NodeType[];
|
||||||
categories: NodeTypeCategory[];
|
categories: NodeTypeCategory[];
|
||||||
|
|
@ -21,10 +23,10 @@ interface NodeSidebarProps {
|
||||||
onToggleCategory: (id: string) => void;
|
onToggleCategory: (id: string) => void;
|
||||||
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
|
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
|
||||||
excludedCategories?: Set<string>;
|
excludedCategories?: Set<string>;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
export const NodeSidebar: React.FC<NodeSidebarProps> = ({ nodeTypes,
|
||||||
nodeTypes,
|
|
||||||
categories,
|
categories,
|
||||||
filter,
|
filter,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
|
@ -32,7 +34,9 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||||
expandedCategories,
|
expandedCategories,
|
||||||
onToggleCategory,
|
onToggleCategory,
|
||||||
excludedCategories,
|
excludedCategories,
|
||||||
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const filteredNodeTypes = useMemo(() => {
|
const filteredNodeTypes = useMemo(() => {
|
||||||
const visible = nodeTypes.filter(
|
const visible = nodeTypes.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
|
|
@ -74,17 +78,17 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||||
return result;
|
return result;
|
||||||
}, [groupedByCategory]);
|
}, [groupedByCategory]);
|
||||||
|
|
||||||
const getLabelFn = (t: string | Record<string, string> | undefined, lang?: string) =>
|
const getLabelFn = (multilingual: string | Record<string, string> | undefined, lang?: string) =>
|
||||||
getLabel(t, lang ?? language);
|
getLabel(multilingual, lang ?? language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.sidebar}>
|
<div className={styles.sidebar} style={style}>
|
||||||
<div className={styles.sidebarHeader}>
|
<div className={styles.sidebarHeader}>
|
||||||
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={styles.sidebarSearch}
|
className={styles.sidebarSearch}
|
||||||
placeholder="Nodes durchsuchen..."
|
placeholder={t('Nodes durchsuchen')}
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => onFilterChange(e.target.value)}
|
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 React, { useState, useEffect } from 'react';
|
||||||
import type { WorkflowEntryPoint } from '../../../api/automation2Api';
|
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
|
||||||
import {
|
import {
|
||||||
getPrimaryStartKind,
|
getPrimaryStartKind,
|
||||||
buildInvocationsForPrimaryKind,
|
buildInvocationsForPrimaryKind,
|
||||||
} from '../nodes/runtime/workflowStartSync';
|
} from '../nodes/runtime/workflowStartSync';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
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, …). */
|
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
|
||||||
const KIND_OPTIONS: { value: string; label: string }[] = [
|
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
|
||||||
{ value: 'manual', label: 'Manueller Trigger' },
|
return [
|
||||||
{ value: 'form', label: 'Formular' },
|
{ value: 'manual', label: t('Manueller Trigger') },
|
||||||
{ value: 'schedule', label: 'Zeitplan' },
|
{ value: 'form', label: t('Formular') },
|
||||||
{ value: 'always_on', label: 'Immer aktiv' },
|
{ value: 'schedule', label: t('Zeitplan') },
|
||||||
];
|
{ value: 'always_on', label: t('Immer aktiv') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkflowConfigurationModalProps {
|
interface WorkflowConfigurationModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -25,19 +29,22 @@ interface WorkflowConfigurationModalProps {
|
||||||
onApply: (next: WorkflowEntryPoint[]) => void;
|
onApply: (next: WorkflowEntryPoint[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
|
||||||
|
|
||||||
function normalizeLoadedKind(k: string): string {
|
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 (['email', 'webhook', 'event'].includes(k)) return 'always_on';
|
||||||
if (k === 'api') return 'manual';
|
if (k === 'api') return 'manual';
|
||||||
return 'manual';
|
return 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({
|
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
|
||||||
open,
|
|
||||||
onClose,
|
onClose,
|
||||||
invocations,
|
invocations,
|
||||||
onApply,
|
onApply,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const kindOptions = _getKindOptions(t);
|
||||||
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
|
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
|
||||||
const [titleDe, setTitleDe] = useState('');
|
const [titleDe, setTitleDe] = useState('');
|
||||||
|
|
||||||
|
|
@ -46,9 +53,9 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
|
||||||
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
|
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
|
||||||
setKind(k);
|
setKind(k);
|
||||||
const entry = invocations[0];
|
const entry = invocations[0];
|
||||||
const t = entry?.title;
|
const entryTitle = entry?.title;
|
||||||
if (typeof t === 'string') setTitleDe(t);
|
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
|
||||||
else if (t && typeof t === 'object') setTitleDe(t.de || t.en || '');
|
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
|
||||||
else setTitleDe('');
|
else setTitleDe('');
|
||||||
}, [open, invocations]);
|
}, [open, invocations]);
|
||||||
|
|
||||||
|
|
@ -57,7 +64,7 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const label =
|
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);
|
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
|
||||||
onApply(next);
|
onApply(next);
|
||||||
onClose();
|
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.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
|
||||||
<div className={styles.workflowModal}>
|
<div className={styles.workflowModal}>
|
||||||
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
|
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
|
||||||
Workflow-Konfiguration
|
{t('Workflow-Konfiguration')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={styles.workflowModalHint}>
|
<p className={styles.workflowModalHint}>
|
||||||
Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem
|
{t(
|
||||||
gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).
|
'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>
|
</p>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
|
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
|
||||||
Titel der Start Node
|
{t('Titel der Start Node')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="wf-start-title"
|
id="wf-start-title"
|
||||||
className={styles.workflowModalInput}
|
className={styles.workflowModalInput}
|
||||||
value={titleDe}
|
value={titleDe}
|
||||||
onChange={(e) => setTitleDe(e.target.value)}
|
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">
|
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
|
||||||
{KIND_OPTIONS.map((o) => (
|
{kindOptions.map((o) => (
|
||||||
<label key={o.value} className={styles.workflowModalRadio}>
|
<label key={o.value} className={styles.workflowModalRadio}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
@ -102,10 +110,10 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
|
||||||
|
|
||||||
<div className={styles.workflowModalActions}>
|
<div className={styles.workflowModalActions}>
|
||||||
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
||||||
Abbrechen
|
{t('Abbrechen')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className={styles.workflowModalBtnPrimary}>
|
<button type="submit" className={styles.workflowModalBtnPrimary}>
|
||||||
Übernehmen
|
{t('Übernehmen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 { FlowCanvas } from './editor/FlowCanvas';
|
||||||
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
||||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||||
|
|
@ -8,4 +9,4 @@ export { CanvasHeader } from './editor/CanvasHeader';
|
||||||
export * from './nodes/shared/utils';
|
export * from './nodes/shared/utils';
|
||||||
export * from './nodes/shared/constants';
|
export * from './nodes/shared/constants';
|
||||||
export * from './nodes/shared/graphUtils';
|
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 React, { useEffect, useState } from 'react';
|
||||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
import type { FormField, NodeConfigRendererProps } from '../configs/types';
|
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||||
import { fetchConnections, type UserConnection } from '../../../../api/automation2Api';
|
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
params,
|
|
||||||
|
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
updateParam,
|
updateParam,
|
||||||
instanceId,
|
instanceId,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const fields = (params.fields as FormField[]) ?? [];
|
const fields = (params.fields as FormField[]) ?? [];
|
||||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||||
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||||
|
|
@ -55,7 +57,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label>Felder</label>
|
<label>{t('Felder')}</label>
|
||||||
<div className={styles.formFieldsList}>
|
<div className={styles.formFieldsList}>
|
||||||
{fields.map((f, i) => (
|
{fields.map((f, i) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -74,7 +76,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
<div className={styles.formFieldRowHeader}>
|
<div className={styles.formFieldRowHeader}>
|
||||||
<span
|
<span
|
||||||
className={styles.formFieldDragHandle}
|
className={styles.formFieldDragHandle}
|
||||||
title="Zum Verschieben ziehen"
|
title={t('Zum Verschieben ziehen')}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.setData('text/plain', String(i));
|
e.dataTransfer.setData('text/plain', String(i));
|
||||||
|
|
@ -85,7 +87,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.formFieldInputs}>
|
<div className={styles.formFieldInputs}>
|
||||||
<input
|
<input
|
||||||
placeholder="name"
|
placeholder={t('name')}
|
||||||
value={f.name ?? ''}
|
value={f.name ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
|
|
@ -94,7 +96,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="label"
|
placeholder={t('label')}
|
||||||
value={f.label ?? ''}
|
value={f.label ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
|
|
@ -109,13 +111,13 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
value={f.type ?? 'string'}
|
value={f.type ?? 'string'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
const t = e.target.value;
|
const fieldType = e.target.value;
|
||||||
next[i] = {
|
next[i] = {
|
||||||
...next[i],
|
...next[i],
|
||||||
type: t,
|
type: fieldType,
|
||||||
...(t === 'clickup_tasks'
|
...(fieldType === 'clickup_tasks'
|
||||||
? { clickupStatusOptions: undefined }
|
? { clickupStatusOptions: undefined }
|
||||||
: t === 'clickup_status'
|
: fieldType === 'clickup_status'
|
||||||
? { clickupConnectionId: undefined, clickupListId: undefined }
|
? { clickupConnectionId: undefined, clickupListId: undefined }
|
||||||
: {
|
: {
|
||||||
clickupConnectionId: undefined,
|
clickupConnectionId: undefined,
|
||||||
|
|
@ -127,12 +129,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
}}
|
}}
|
||||||
style={{ width: 'auto', minWidth: 90 }}
|
style={{ width: 'auto', minWidth: 90 }}
|
||||||
>
|
>
|
||||||
<option value="string">Text</option>
|
<option value="string">{t('Text')}</option>
|
||||||
<option value="number">Number</option>
|
<option value="number">{t('Zahl')}</option>
|
||||||
<option value="date">Date</option>
|
<option value="date">{t('Datum')}</option>
|
||||||
<option value="boolean">Checkbox</option>
|
<option value="boolean">{t('Kontrollkästchen')}</option>
|
||||||
<option value="clickup_tasks">ClickUp-Aufgabe (Referenz)</option>
|
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
|
||||||
<option value="clickup_status">ClickUp-Status (Liste)</option>
|
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||||
</select>
|
</select>
|
||||||
<label className={styles.formFieldRequiredLabel}>
|
<label className={styles.formFieldRequiredLabel}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -144,12 +146,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
updateParam('fields', next);
|
updateParam('fields', next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
Pflichtfeld
|
{t('Pflichtfeld')}
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeField(i)}
|
onClick={() => removeField(i)}
|
||||||
title="Feld entfernen"
|
title={t('Feld entfernen')}
|
||||||
className={styles.formFieldRemoveButton}
|
className={styles.formFieldRemoveButton}
|
||||||
>
|
>
|
||||||
<FaTimes />
|
<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)' }}>
|
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
|
||||||
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
|
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
|
||||||
<p style={{ margin: '0 0 6px' }}>
|
<p style={{ margin: '0 0 6px' }}>
|
||||||
Dropdown mit {f.clickupStatusOptions.length} Status aus der ClickUp-Liste (Wert = exakter
|
{t(
|
||||||
Status-Name für die API).
|
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
|
||||||
|
{ count: String(f.clickupStatusOptions.length) }
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ margin: '0 0 6px' }}>
|
<p style={{ margin: '0 0 6px' }}>
|
||||||
Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste
|
{t(
|
||||||
abgleichen“.
|
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,7 +178,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
{f.type === 'clickup_tasks' ? (
|
{f.type === 'clickup_tasks' ? (
|
||||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
|
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
||||||
ClickUp-Verbindung
|
{t('ClickUp-Verbindung')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={f.clickupConnectionId ?? ''}
|
value={f.clickupConnectionId ?? ''}
|
||||||
|
|
@ -185,7 +190,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
disabled={connectionsLoading || !instanceId}
|
disabled={connectionsLoading || !instanceId}
|
||||||
style={{ width: '100%', marginBottom: 8 }}
|
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) => (
|
{connections.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.externalUsername ?? c.id}
|
{c.externalUsername ?? c.id}
|
||||||
|
|
@ -193,10 +198,10 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
placeholder="z. B. aus ClickUp-URL …/list/123456789"
|
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
|
||||||
value={f.clickupListId ?? ''}
|
value={f.clickupListId ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
|
|
@ -206,9 +211,9 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
|
||||||
Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:{' '}
|
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
|
||||||
<code>{'{ add: [taskId], rem: [] }'}</code> — im ClickUp-Node per Datenquelle auf das
|
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
|
||||||
Formularfeld mappen.
|
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -220,7 +225,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
+ Feld
|
+ {t('Feld')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import { isRef } from '../shared/dataRef';
|
import { isRef } from '../shared/dataRef';
|
||||||
|
|
@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
|
||||||
import { operatorsForType } from '../shared/conditionOperators';
|
import { operatorsForType } from '../shared/conditionOperators';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export interface StructuredCondition {
|
export interface StructuredCondition {
|
||||||
type: 'condition';
|
type: 'condition';
|
||||||
ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null;
|
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 }) => {
|
export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
|
|
||||||
const cond = parseCondition(params.condition);
|
const cond = parseCondition(params.condition);
|
||||||
|
|
@ -96,8 +99,8 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
||||||
return (
|
return (
|
||||||
<div className={styles.ifElseConditionEditor}>
|
<div className={styles.ifElseConditionEditor}>
|
||||||
<div className={styles.ifElseConditionRow}>
|
<div className={styles.ifElseConditionRow}>
|
||||||
<label>Datenquelle</label>
|
<label>{t('Datenquelle')}</label>
|
||||||
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder="Formular-Feld wählen…" />
|
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('Formularfeld wählen')} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.ifElseConditionRow}>
|
<div className={styles.ifElseConditionRow}>
|
||||||
<label>Vergleich</label>
|
<label>Vergleich</label>
|
||||||
|
|
@ -111,13 +114,13 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
||||||
</div>
|
</div>
|
||||||
{needsValue && (
|
{needsValue && (
|
||||||
<div className={styles.ifElseConditionRow}>
|
<div className={styles.ifElseConditionRow}>
|
||||||
<label>Wert</label>
|
<label>{t('Wert')}</label>
|
||||||
{mimeTypeOptions.length > 0 ? (
|
{mimeTypeOptions.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
onChange={(e) => handleValueChange(e.target.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) => (
|
{mimeTypeOptions.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
{o.label} ({o.value})
|
{o.label} ({o.value})
|
||||||
|
|
@ -139,8 +142,8 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
||||||
: fieldType === 'date'
|
: fieldType === 'date'
|
||||||
? 'TT.MM.JJJJ'
|
? 'TT.MM.JJJJ'
|
||||||
: isMimeTypeRef
|
: isMimeTypeRef
|
||||||
? 'z.B. application/pdf'
|
? t('z.B. application/pdf')
|
||||||
: 'z.B. CH'
|
: t('z.B. ch')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import { LoopItemsSelect } from '../shared/LoopItemsSelect';
|
import { LoopItemsSelect } from '../shared/LoopItemsSelect';
|
||||||
import { createValue, isRef, isValue } from '../shared/dataRef';
|
import { createValue, isRef, isValue } from '../shared/dataRef';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||||
import type { NodeType } from '../../../../api/automation2Api';
|
import type { NodeType } from '../../../../api/workflowApi';
|
||||||
import type { WorkflowEntryPoint } from '../../../../api/automation2Api';
|
import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
|
||||||
import { getLabel } from '../shared/utils';
|
import { getLabel } from '../shared/utils';
|
||||||
|
|
||||||
export const CANVAS_START_NODE_ID = 'start';
|
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,
|
createValue,
|
||||||
formatRefLabel,
|
formatRefLabel,
|
||||||
type DataRef,
|
type DataRef,
|
||||||
|
type SystemVarRef,
|
||||||
} from './dataRef';
|
} from './dataRef';
|
||||||
import { RefSourceSelect } from './RefSourceSelect';
|
import { RefSourceSelect } from './RefSourceSelect';
|
||||||
import { DataPicker } from './DataPicker';
|
import { DataPicker } from './DataPicker';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export type FieldType = 'textarea' | 'input';
|
export type FieldType = 'textarea' | 'input';
|
||||||
|
|
||||||
interface DynamicValueFieldProps {
|
interface DynamicValueFieldProps {
|
||||||
|
|
@ -28,14 +31,14 @@ interface DynamicValueFieldProps {
|
||||||
variant?: 'picker' | 'dropdown';
|
variant?: 'picker' | 'dropdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
|
export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({ paramKey,
|
||||||
paramKey,
|
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
variant = 'picker',
|
variant = 'picker',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -47,7 +50,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
|
||||||
});
|
});
|
||||||
const canUseRef = dataFlow !== null && hasUsefulSources;
|
const canUseRef = dataFlow !== null && hasUsefulSources;
|
||||||
|
|
||||||
const handleSetRef = (newRef: DataRef | null) => {
|
const handleSetRef = (newRef: DataRef | SystemVarRef | null) => {
|
||||||
onChange(paramKey, newRef ?? createValue(''));
|
onChange(paramKey, newRef ?? createValue(''));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -55,7 +58,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className={styles.dynamicValueField}>
|
<div className={styles.dynamicValueField}>
|
||||||
<label>{label}</label>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext
|
||||||
import { isRef, isValue, createValue } from './dataRef';
|
import { isRef, isValue, createValue } from './dataRef';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
function parseHybrid(value: unknown): { staticStr: string } {
|
function parseHybrid(value: unknown): { staticStr: string } {
|
||||||
if (isRef(value)) return { staticStr: '' };
|
if (isRef(value)) return { staticStr: '' };
|
||||||
if (isValue(value)) {
|
if (isValue(value)) {
|
||||||
|
|
@ -37,8 +39,7 @@ export interface HybridStaticRefFieldProps {
|
||||||
pathPickMode?: PathPickMode;
|
pathPickMode?: PathPickMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
|
export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({ label,
|
||||||
label,
|
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
multiline,
|
multiline,
|
||||||
|
|
@ -46,6 +47,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
|
||||||
placeholder,
|
placeholder,
|
||||||
pathPickMode = 'default',
|
pathPickMode = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
const hasSources =
|
const hasSources =
|
||||||
dataFlow &&
|
dataFlow &&
|
||||||
|
|
@ -83,7 +85,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
|
||||||
<StatischKontextSelect
|
<StatischKontextSelect
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder="— Quelle wählen —"
|
placeholder={t('Quelle wählen')}
|
||||||
pathPickMode={pathPickMode}
|
pathPickMode={pathPickMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -9,6 +9,8 @@ import { refToOptionValue, optionValueToRef } from './RefSourceSelect';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface LoopOption {
|
interface LoopOption {
|
||||||
ref: DataRef;
|
ref: DataRef;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -33,7 +35,8 @@ function buildLoopOptions(
|
||||||
sourceIds: string[],
|
sourceIds: string[],
|
||||||
nodes: Array<{ id: string; type?: string; title?: string; parameters?: Record<string, unknown> }>,
|
nodes: Array<{ id: string; type?: string; title?: string; parameters?: Record<string, unknown> }>,
|
||||||
nodeOutputsPreview: 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[] {
|
): LoopOption[] {
|
||||||
const options: LoopOption[] = [];
|
const options: LoopOption[] = [];
|
||||||
|
|
||||||
|
|
@ -48,13 +51,13 @@ function buildLoopOptions(
|
||||||
if (node?.type === 'trigger.form') {
|
if (node?.type === 'trigger.form') {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['payload']),
|
ref: createRef(nodeId, ['payload']),
|
||||||
label: `Alle Formularfelder (${nodeLabel})`,
|
label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
const filesVal = getValueAtPath(preview, ['files']);
|
const filesVal = getValueAtPath(preview, ['files']);
|
||||||
if (Array.isArray(filesVal)) {
|
if (Array.isArray(filesVal)) {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['files']),
|
ref: createRef(nodeId, ['files']),
|
||||||
label: `Alle Dateien aus Formular (${nodeLabel})`,
|
label: `${translate('Alle Dateien aus Formular')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -63,7 +66,7 @@ function buildLoopOptions(
|
||||||
if (node?.type === 'input.form') {
|
if (node?.type === 'input.form') {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, []),
|
ref: createRef(nodeId, []),
|
||||||
label: `Alle Formularfelder (${nodeLabel})`,
|
label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -71,11 +74,11 @@ function buildLoopOptions(
|
||||||
if (node?.type === 'input.upload') {
|
if (node?.type === 'input.upload') {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['files']),
|
ref: createRef(nodeId, ['files']),
|
||||||
label: `Alle hochgeladenen Dateien (${nodeLabel})`,
|
label: `${translate('Alle hochgeladenen Dateien')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['fileIds']),
|
ref: createRef(nodeId, ['fileIds']),
|
||||||
label: `Alle Datei-IDs (${nodeLabel})`,
|
label: `${translate('Alle Datei-IDs')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +86,7 @@ function buildLoopOptions(
|
||||||
if (node?.type === 'flow.loop') {
|
if (node?.type === 'flow.loop') {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['items']),
|
ref: createRef(nodeId, ['items']),
|
||||||
label: `Alle Elemente aus Schleife (${nodeLabel})`,
|
label: `${translate('Alle Elemente aus Schleife')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +94,7 @@ function buildLoopOptions(
|
||||||
if (node?.type === 'email.searchEmail') {
|
if (node?.type === 'email.searchEmail') {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['data', 'searchResults', 'results']),
|
ref: createRef(nodeId, ['data', 'searchResults', 'results']),
|
||||||
label: `Alle gefundenen E-Mails (${nodeLabel})`,
|
label: `${translate('Alle gefundenen E-Mails')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +102,7 @@ function buildLoopOptions(
|
||||||
if (node?.type === 'email.checkEmail') {
|
if (node?.type === 'email.checkEmail') {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['data', 'emails', 'emails']),
|
ref: createRef(nodeId, ['data', 'emails', 'emails']),
|
||||||
label: `Alle E-Mails (${nodeLabel})`,
|
label: `${translate('Alle E-Mails')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +110,7 @@ function buildLoopOptions(
|
||||||
if (node?.type === 'sharepoint.listFiles') {
|
if (node?.type === 'sharepoint.listFiles') {
|
||||||
options.push({
|
options.push({
|
||||||
ref: createRef(nodeId, ['files']),
|
ref: createRef(nodeId, ['files']),
|
||||||
label: `Alle Dateien (${nodeLabel})`,
|
label: `${translate('Alle Dateien')} (${nodeLabel})`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -153,11 +156,11 @@ interface LoopItemsSelectProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
|
||||||
value,
|
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Über was soll iteriert werden?',
|
placeholder,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
if (!dataFlow) return null;
|
if (!dataFlow) return null;
|
||||||
|
|
||||||
|
|
@ -165,7 +168,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
||||||
if (sourceIds.length === 0) {
|
if (sourceIds.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className={styles.dynamicValueEmptyHint}>
|
<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>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +177,8 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
||||||
sourceIds,
|
sourceIds,
|
||||||
dataFlow.nodes,
|
dataFlow.nodes,
|
||||||
dataFlow.nodeOutputsPreview,
|
dataFlow.nodeOutputsPreview,
|
||||||
dataFlow.getNodeLabel
|
dataFlow.getNodeLabel,
|
||||||
|
t
|
||||||
);
|
);
|
||||||
|
|
||||||
const ref = isRef(value) ? value : null;
|
const ref = isRef(value) ? value : null;
|
||||||
|
|
@ -182,7 +186,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.ifElseConditionRow}>
|
<div className={styles.ifElseConditionRow}>
|
||||||
<label>Datenquelle für Iteration</label>
|
<label>{t('Datenquelle für Iteration')}</label>
|
||||||
<select
|
<select
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -196,7 +200,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
||||||
}}
|
}}
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
>
|
>
|
||||||
<option value="">{placeholder}</option>
|
<option value="">{placeholder ?? t('Über was soll iteriert werden?')}</option>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|
@ -204,7 +208,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className={styles.nodeConfigNameHint}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
|
import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
/** How to build path options for StatischKontextSelect / RefSourceSelect. */
|
/** How to build path options for StatischKontextSelect / RefSourceSelect. */
|
||||||
export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
|
export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
|
||||||
|
|
@ -131,6 +132,11 @@ export function refToOptionValue(ref: DataRef): string {
|
||||||
return JSON.stringify(ref);
|
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 {
|
export function optionValueToRef(s: string): DataRef | null {
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(s) as unknown;
|
const o = JSON.parse(s) as unknown;
|
||||||
|
|
@ -190,10 +196,11 @@ interface StatischKontextSelectProps {
|
||||||
export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = '— Quelle wählen —',
|
placeholder,
|
||||||
staticLabel = 'Statisch',
|
staticLabel,
|
||||||
pathPickMode = 'default',
|
pathPickMode = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
if (!dataFlow) return null;
|
if (!dataFlow) return null;
|
||||||
|
|
||||||
|
|
@ -213,7 +220,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
||||||
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
||||||
const paths = pickPathsForNode(node, preview, pathPickMode);
|
const paths = pickPathsForNode(node, preview, pathPickMode);
|
||||||
for (const p of paths) {
|
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({
|
options.push({
|
||||||
ref: createRef(nodeId, p.path),
|
ref: createRef(nodeId, p.path),
|
||||||
label: displayLabel,
|
label: displayLabel,
|
||||||
|
|
@ -245,8 +253,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
|
||||||
if (ref) onChange(ref);
|
if (ref) onChange(ref);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">{placeholder}</option>
|
<option value="">{placeholder ?? t('— Quelle wählen —')}</option>
|
||||||
<option value={STATIC_SOURCE_VALUE}>{staticLabel}</option>
|
<option value={STATIC_SOURCE_VALUE}>{staticLabel ?? t('Statisch')}</option>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|
@ -267,9 +275,10 @@ interface RefSourceSelectProps {
|
||||||
export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Datenquelle wählen…',
|
placeholder,
|
||||||
pathPickMode = 'default',
|
pathPickMode = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
if (!dataFlow) return null;
|
if (!dataFlow) return null;
|
||||||
|
|
||||||
|
|
@ -289,7 +298,8 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
||||||
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
|
||||||
const paths = pickPathsForNode(node, preview, pathPickMode);
|
const paths = pickPathsForNode(node, preview, pathPickMode);
|
||||||
for (const p of paths) {
|
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({
|
options.push({
|
||||||
ref: createRef(nodeId, p.path),
|
ref: createRef(nodeId, p.path),
|
||||||
label: displayLabel,
|
label: displayLabel,
|
||||||
|
|
@ -312,7 +322,7 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
|
||||||
if (ref) onChange(ref);
|
if (ref) onChange(ref);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">{placeholder}</option>
|
<option value="">{placeholder ?? t('Datenquelle wählen…')}</option>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|
@ -343,13 +353,13 @@ function getFormFieldType(
|
||||||
if (!fieldName) return null;
|
if (!fieldName) return null;
|
||||||
const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName);
|
const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName);
|
||||||
if (!field || typeof field !== 'object') return null;
|
if (!field || typeof field !== 'object') return null;
|
||||||
const t = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
|
const rawFieldType = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
|
||||||
if (t === 'number') return 'number';
|
if (rawFieldType === 'number') return 'number';
|
||||||
if (t === 'email') return 'email';
|
if (rawFieldType === 'email') return 'email';
|
||||||
if (t === 'date' || t === 'datetime') return 'date';
|
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
|
||||||
if (t === 'boolean' || t === 'checkbox') return 'boolean';
|
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
|
||||||
if (t === 'clickup_tasks') return 'string';
|
if (rawFieldType === 'clickup_tasks') return 'string';
|
||||||
if (t === 'clickup_status') return 'string';
|
if (rawFieldType === 'clickup_status') return 'string';
|
||||||
return 'string';
|
return 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,6 +243,14 @@ export function buildSyncFromClickUpList(args: {
|
||||||
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
|
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
|
||||||
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
|
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
|
||||||
];
|
];
|
||||||
|
if (statusOpts.length > 0) {
|
||||||
|
standardTrigger.splice(2, 0, {
|
||||||
|
name: PAYLOAD_STATUS,
|
||||||
|
label: 'Status',
|
||||||
|
type: 'clickup_status',
|
||||||
|
statusOptions: statusOpts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const customInput: FormField[] = [];
|
const customInput: FormField[] = [];
|
||||||
const customTrigger: TriggerFormFieldRow[] = [];
|
const customTrigger: TriggerFormFieldRow[] = [];
|
||||||
|
|
@ -16,10 +16,25 @@ export interface DataValue {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Union: either a reference or a static value */
|
/** System variable reference */
|
||||||
export type DynamicValue = DataRef | DataValue;
|
export interface SystemVarRef {
|
||||||
|
type: 'system';
|
||||||
|
variable: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union: reference, static value, or system variable */
|
||||||
|
export type DynamicValue = DataRef | DataValue | SystemVarRef;
|
||||||
|
|
||||||
/** Type guards */
|
/** 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 {
|
export function isRef(v: unknown): v is DataRef {
|
||||||
return (
|
return (
|
||||||
typeof v === 'object' &&
|
typeof v === 'object' &&
|
||||||
|
|
@ -39,7 +54,12 @@ export function isValue(v: unknown): v is DataValue {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDynamicValue(v: unknown): v is DynamicValue {
|
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 */
|
/** Create a reference object */
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
Automation2Graph,
|
Automation2Graph,
|
||||||
Automation2GraphNode,
|
Automation2GraphNode,
|
||||||
Automation2Connection,
|
Automation2Connection,
|
||||||
} from '../../../../api/automation2Api';
|
} from '../../../../api/workflowApi';
|
||||||
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
||||||
|
|
||||||
export function fromApiGraph(
|
export function fromApiGraph(
|
||||||
|
|
@ -27,6 +27,7 @@ export function fromApiGraph(
|
||||||
const cases = (n.parameters?.cases as unknown[]) ?? [];
|
const cases = (n.parameters?.cases as unknown[]) ?? [];
|
||||||
outputs = Math.max(1, cases.length);
|
outputs = Math.max(1, cases.length);
|
||||||
}
|
}
|
||||||
|
const nt = nodeTypes.find((t) => t.id === n.type);
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: n.type,
|
type: n.type,
|
||||||
|
|
@ -37,6 +38,12 @@ export function fromApiGraph(
|
||||||
inputs: io.inputs,
|
inputs: io.inputs,
|
||||||
outputs,
|
outputs,
|
||||||
parameters: n.parameters ?? {},
|
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,
|
title: n.title,
|
||||||
comment: n.comment,
|
comment: n.comment,
|
||||||
parameters: n.parameters ?? {},
|
parameters: n.parameters ?? {},
|
||||||
|
inputPorts: n.inputPorts,
|
||||||
|
outputPorts: n.outputPorts,
|
||||||
})),
|
})),
|
||||||
connections: connections.map((c) => {
|
connections: connections.map((c) => {
|
||||||
const srcNode = nodeMap.get(c.sourceId);
|
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
|
* 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). */
|
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
|
||||||
export type FormField = {
|
export type FormField = {
|
||||||
|
|
@ -23,3 +23,12 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
|
||||||
|
|
||||||
/** Function type for resolving localized labels */
|
/** Function type for resolving localized labels */
|
||||||
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
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 React, { useMemo } from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
type FormField = {
|
type FormField = {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -15,17 +17,17 @@ type FormField = {
|
||||||
|
|
||||||
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
|
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;
|
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) => {
|
return raw.map((f, i) => {
|
||||||
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
||||||
const o = f as Record<string, unknown>;
|
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 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 = (
|
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'];
|
) as FormField['type'];
|
||||||
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
|
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -37,12 +39,13 @@ function parseFields(params: Record<string, unknown>): FormField[] {
|
||||||
}
|
}
|
||||||
return { name, label, type };
|
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 }) => {
|
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[]) => {
|
const setFields = (next: FormField[]) => {
|
||||||
updateParam('formFields', next);
|
updateParam('formFields', next);
|
||||||
|
|
@ -51,15 +54,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
return (
|
return (
|
||||||
<div className={styles.startNodeDoc}>
|
<div className={styles.startNodeDoc}>
|
||||||
<p className={styles.startNodeDocIntro}>
|
<p className={styles.startNodeDocIntro}>
|
||||||
<strong>Formular-Felder</strong> werden beim Start ausgefüllt und liegen unter{' '}
|
<strong>{t('Formular-Felder')}</strong>{' '}
|
||||||
<code>payload.<name></code> in der Start-Ausgabe.
|
{t('werden beim Start ausgefüllt und liegen unter')}{' '}
|
||||||
|
<code>payload.<name></code> {t('in der Start-Ausgabe.')}
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.formFieldsList}>
|
<div className={styles.formFieldsList}>
|
||||||
{fields.map((f, idx) => (
|
{fields.map((f, idx) => (
|
||||||
<div key={idx} className={styles.formFieldRow}>
|
<div key={idx} className={styles.formFieldRow}>
|
||||||
<input
|
<input
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
placeholder="Name (Payload-Key)"
|
placeholder={t('Name (Payload-Key)')}
|
||||||
value={f.name}
|
value={f.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
|
|
@ -69,7 +73,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
placeholder="Beschriftung"
|
placeholder={t('Beschriftung')}
|
||||||
value={f.label}
|
value={f.label}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
|
|
@ -82,21 +86,21 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
value={f.type}
|
value={f.type}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
const t = e.target.value as FormField['type'];
|
const fieldType = e.target.value as FormField['type'];
|
||||||
if (t === 'clickup_status') {
|
if (fieldType === 'clickup_status') {
|
||||||
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
|
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
|
||||||
} else {
|
} else {
|
||||||
next[idx] = { name: f.name, label: f.label, type: t };
|
next[idx] = { name: f.name, label: f.label, type: fieldType };
|
||||||
}
|
}
|
||||||
setFields(next);
|
setFields(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="text">Text</option>
|
<option value="text">{t('Text')}</option>
|
||||||
<option value="number">Zahl</option>
|
<option value="number">{t('Zahl')}</option>
|
||||||
<option value="email">E-Mail</option>
|
<option value="email">{t('E-Mail')}</option>
|
||||||
<option value="date">Datum</option>
|
<option value="date">{t('Datum')}</option>
|
||||||
<option value="boolean">Ja/Nein</option>
|
<option value="boolean">{t('Ja/Nein')}</option>
|
||||||
<option value="clickup_status">ClickUp-Status (Liste)</option>
|
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -111,10 +115,10 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.startsAddBtn}
|
className={styles.startsAddBtn}
|
||||||
onClick={() =>
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
type ScheduleSpec,
|
type ScheduleSpec,
|
||||||
type ScheduleMode,
|
type ScheduleMode,
|
||||||
|
|
@ -16,36 +16,35 @@ import {
|
||||||
} from '../runtime/scheduleCron';
|
} from '../runtime/scheduleCron';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
const MODE_OPTIONS: { value: ScheduleMode; title: string; subtitle: string }[] = [
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MONTH_NAMES_DE = [
|
function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
|
||||||
'Januar',
|
return [
|
||||||
'Februar',
|
{ value: 'daily', title: t('Täglich'), subtitle: t('Jeden Tag zur gleichen Zeit') },
|
||||||
'März',
|
{ value: 'weekdays', title: t('Werktage'), subtitle: t('Montag bis Freitag') },
|
||||||
'April',
|
{ value: 'weekly', title: t('Bestimmte Tage'), subtitle: t('Wochentage auswählen') },
|
||||||
'Mai',
|
{ value: 'calendar', title: t('Ein anderer Zeitraum'), subtitle: t('Monatlich oder jährlich') },
|
||||||
'Juni',
|
{ value: 'interval', title: t('Intervall'), subtitle: t('In regelmäßigen Abständen') },
|
||||||
'Juli',
|
];
|
||||||
'August',
|
}
|
||||||
'September',
|
|
||||||
'Oktober',
|
|
||||||
'November',
|
|
||||||
'Dezember',
|
|
||||||
];
|
|
||||||
|
|
||||||
const INTERVAL_UNITS: { value: IntervalUnit; label: string; title: string }[] = [
|
function _monthNames(t: (k: string) => string): string[] {
|
||||||
{ value: 'seconds', label: 'sek', title: 'Sekunden' },
|
return [
|
||||||
{ value: 'minutes', label: 'min', title: 'Minuten' },
|
t('Januar'), t('Februar'), t('März'), t('April'),
|
||||||
{ value: 'hours', label: 'h', title: 'Stunden' },
|
t('Mai'), t('Juni'), t('Juli'), t('August'),
|
||||||
{ value: 'days', label: 'd', title: 'Tage' },
|
t('September'), t('Oktober'), t('November'), t('Dezember'),
|
||||||
{ value: 'years', label: 'a', title: 'Jahre' },
|
];
|
||||||
];
|
}
|
||||||
|
|
||||||
|
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 {
|
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')}`;
|
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;
|
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
|
||||||
|
|
||||||
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
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 [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
|
||||||
const prefersReducedMotion = useReducedMotion();
|
const prefersReducedMotion = useReducedMotion();
|
||||||
const specModeRef = useRef(spec.mode);
|
const specModeRef = useRef(spec.mode);
|
||||||
|
|
@ -128,7 +130,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
const onModeCardPointerEvent = (
|
const onModeCardPointerEvent = (
|
||||||
phase: 'pointerdown' | 'click',
|
phase: 'pointerdown' | 'click',
|
||||||
e: React.PointerEvent | React.MouseEvent,
|
e: React.PointerEvent | React.MouseEvent,
|
||||||
o: (typeof MODE_OPTIONS)[number]
|
o: { value: ScheduleMode; title: string; subtitle: string }
|
||||||
) => {
|
) => {
|
||||||
const el = e.target as HTMLElement;
|
const el = e.target as HTMLElement;
|
||||||
const cur = e.currentTarget as HTMLElement;
|
const cur = e.currentTarget as HTMLElement;
|
||||||
|
|
@ -200,13 +202,14 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
return (
|
return (
|
||||||
<div className={styles.schedulePanel}>
|
<div className={styles.schedulePanel}>
|
||||||
<p className={styles.startNodeDocIntro}>
|
<p className={styles.startNodeDocIntro}>
|
||||||
Legen Sie fest, <strong>wann</strong> dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird
|
{t(
|
||||||
unten automatisch erzeugt.
|
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<LayoutGroup>
|
<LayoutGroup>
|
||||||
<div className={styles.scheduleModeStack}>
|
<div className={styles.scheduleModeStack}>
|
||||||
{MODE_OPTIONS.map((o) => (
|
{modeOptions.map((o) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={o.value}
|
key={o.value}
|
||||||
data-schedule-mode={o.value}
|
data-schedule-mode={o.value}
|
||||||
|
|
@ -246,7 +249,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
<div className={styles.scheduleModeConfig}>
|
<div className={styles.scheduleModeConfig}>
|
||||||
{o.value === 'daily' && (
|
{o.value === 'daily' && (
|
||||||
<label className={styles.scheduleFieldRow}>
|
<label className={styles.scheduleFieldRow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
step={60}
|
step={60}
|
||||||
|
|
@ -259,7 +262,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
|
|
||||||
{o.value === 'weekdays' && (
|
{o.value === 'weekdays' && (
|
||||||
<label className={styles.scheduleFieldRow}>
|
<label className={styles.scheduleFieldRow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
step={60}
|
step={60}
|
||||||
|
|
@ -273,9 +276,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
{o.value === 'weekly' && (
|
{o.value === 'weekly' && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.scheduleFieldCol}>
|
<div className={styles.scheduleFieldCol}>
|
||||||
<span className={styles.scheduleFieldLabel}>Wochentage</span>
|
<span className={styles.scheduleFieldLabel}>{t('Wochentage')}</span>
|
||||||
<div className={styles.scheduleWeekdayToggles}>
|
<div className={styles.scheduleWeekdayToggles}>
|
||||||
{WEEKDAYS_MO_SO.map(({ cronDow, label }) => (
|
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
|
||||||
<button
|
<button
|
||||||
key={cronDow}
|
key={cronDow}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -284,13 +287,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
}
|
}
|
||||||
onClick={() => toggleWeekday(cronDow)}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className={styles.scheduleFieldRow}>
|
<label className={styles.scheduleFieldRow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
step={60}
|
step={60}
|
||||||
|
|
@ -314,7 +317,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
}
|
}
|
||||||
onClick={() => setCalendarPeriod('monthly')}
|
onClick={() => setCalendarPeriod('monthly')}
|
||||||
>
|
>
|
||||||
Monatlich
|
{t('Monatlich')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -325,13 +328,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
}
|
}
|
||||||
onClick={() => setCalendarPeriod('yearly')}
|
onClick={() => setCalendarPeriod('yearly')}
|
||||||
>
|
>
|
||||||
Jährlich
|
{t('Jährlich')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{spec.calendarPeriod === 'monthly' && (
|
{spec.calendarPeriod === 'monthly' && (
|
||||||
<label className={styles.scheduleFieldRow}>
|
<label className={styles.scheduleFieldRow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Monatstag</span>
|
<span className={styles.scheduleFieldLabel}>{t('Monatstag')}</span>
|
||||||
<select
|
<select
|
||||||
className={styles.scheduleSelect}
|
className={styles.scheduleSelect}
|
||||||
value={spec.monthDay}
|
value={spec.monthDay}
|
||||||
|
|
@ -349,13 +352,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
{spec.calendarPeriod === 'yearly' && (
|
{spec.calendarPeriod === 'yearly' && (
|
||||||
<div className={styles.scheduleYearlyRow}>
|
<div className={styles.scheduleYearlyRow}>
|
||||||
<label className={styles.scheduleFieldRowGrow}>
|
<label className={styles.scheduleFieldRowGrow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Monat</span>
|
<span className={styles.scheduleFieldLabel}>{t('Monat')}</span>
|
||||||
<select
|
<select
|
||||||
className={styles.scheduleSelect}
|
className={styles.scheduleSelect}
|
||||||
value={spec.monthIndex}
|
value={spec.monthIndex}
|
||||||
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
|
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}>
|
<option key={i + 1} value={i + 1}>
|
||||||
{name}
|
{name}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -363,7 +366,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className={styles.scheduleFieldRowGrow}>
|
<label className={styles.scheduleFieldRowGrow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Tag</span>
|
<span className={styles.scheduleFieldLabel}>{t('Tag')}</span>
|
||||||
<select
|
<select
|
||||||
className={styles.scheduleSelect}
|
className={styles.scheduleSelect}
|
||||||
value={spec.monthDay}
|
value={spec.monthDay}
|
||||||
|
|
@ -380,7 +383,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className={styles.scheduleFieldRow}>
|
<label className={styles.scheduleFieldRow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
step={60}
|
step={60}
|
||||||
|
|
@ -394,7 +397,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
|
|
||||||
{o.value === 'interval' && (
|
{o.value === 'interval' && (
|
||||||
<div className={styles.scheduleIntervalRow}>
|
<div className={styles.scheduleIntervalRow}>
|
||||||
<span className={styles.scheduleFieldLabel}>Alle</span>
|
<span className={styles.scheduleFieldLabel}>{t('Alle')}</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
|
@ -411,9 +414,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
|
||||||
className={styles.scheduleUnitSelect}
|
className={styles.scheduleUnitSelect}
|
||||||
value={spec.intervalUnit}
|
value={spec.intervalUnit}
|
||||||
onChange={(e) => setIntervalUnit(e.target.value as 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}>
|
<option key={u.value} value={u.value} title={u.title}>
|
||||||
{u.label}
|
{u.label}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
const SCHEMA_EXAMPLE = `{
|
const SCHEMA_EXAMPLE = `{
|
||||||
"trigger": {
|
"trigger": {
|
||||||
|
|
@ -22,17 +23,20 @@ const SCHEMA_EXAMPLE = `{
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
export const StartNodeConfig: React.FC<NodeConfigRendererProps> = () => {
|
export const StartNodeConfig: React.FC<NodeConfigRendererProps> = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
return (
|
return (
|
||||||
<div className={styles.startNodeDoc}>
|
<div className={styles.startNodeDoc}>
|
||||||
<p className={styles.startNodeDocIntro}>
|
<p className={styles.startNodeDocIntro}>
|
||||||
Die <strong>Start</strong>-Node liefert beim Ausführen immer dieselbe Struktur. Den <strong>Einstiegstyp</strong>{' '}
|
{t(
|
||||||
(manuell, Formular, Zeitplan, …) wählen Sie über das <strong>Zahnrad</strong> oben in der
|
'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.'
|
||||||
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>
|
||||||
<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.startNodeSchema}>
|
||||||
<div className={styles.startNodeSchemaTitle}>Ausgabe-Schema</div>
|
<div className={styles.startNodeSchemaTitle}>{t('Ausgabe-Schema')}</div>
|
||||||
<pre className={styles.startNodePre}>{SCHEMA_EXAMPLE}</pre>
|
<pre className={styles.startNodePre}>{SCHEMA_EXAMPLE}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import { isRef, createValue } from '../shared/dataRef';
|
import { isRef, createValue } from '../shared/dataRef';
|
||||||
|
|
@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
|
||||||
import { operatorsForType } from '../shared/conditionOperators';
|
import { operatorsForType } from '../shared/conditionOperators';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export interface SwitchCase {
|
export interface SwitchCase {
|
||||||
operator: string;
|
operator: string;
|
||||||
value?: string | number | boolean;
|
value?: string | number | boolean;
|
||||||
|
|
@ -31,6 +33,7 @@ function normalizeCase(c: unknown): SwitchCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const dataFlow = useAutomation2DataFlow();
|
const dataFlow = useAutomation2DataFlow();
|
||||||
|
|
||||||
const valueParam = params.value;
|
const valueParam = params.value;
|
||||||
|
|
@ -111,7 +114,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
||||||
onChange={(e) => handleCaseValueChange(index, e.target.value)}
|
onChange={(e) => handleCaseValueChange(index, e.target.value)}
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
>
|
>
|
||||||
<option value="">— MIME-Type wählen —</option>
|
<option value="">{t('MIME-Typ wählen')}</option>
|
||||||
{mimeTypeOptions.map((o) => (
|
{mimeTypeOptions.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
{o.label} ({o.value})
|
{o.label} ({o.value})
|
||||||
|
|
@ -151,9 +154,9 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
||||||
}}
|
}}
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
>
|
>
|
||||||
<option value="">— wählen —</option>
|
<option value="">{t('Wählen')}</option>
|
||||||
<option value="true">Ja / wahr</option>
|
<option value="true">{t('Ja (true)')}</option>
|
||||||
<option value="false">Nein / falsch</option>
|
<option value="false">{t('Nein (false)')}</option>
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +166,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
|
||||||
className={styles.startsInput}
|
className={styles.startsInput}
|
||||||
value={valStr}
|
value={valStr}
|
||||||
onChange={(e) => handleCaseValueChange(index, e.target.value)}
|
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 (
|
return (
|
||||||
<div className={styles.ifElseConditionEditor}>
|
<div className={styles.ifElseConditionEditor}>
|
||||||
<div className={styles.ifElseConditionRow}>
|
<div className={styles.ifElseConditionRow}>
|
||||||
<label>Datenquelle</label>
|
<label>{t('Datenquelle')}</label>
|
||||||
<RefSourceSelect
|
<RefSourceSelect
|
||||||
value={ref}
|
value={ref}
|
||||||
onChange={handleRefChange}
|
onChange={handleRefChange}
|
||||||
placeholder="Feld zum Vergleichen wählen…"
|
placeholder={t('Feld zum Vergleich wählen')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!ref && (
|
{!ref && (
|
||||||
<div className={styles.ifElseConditionRow}>
|
<div className={styles.ifElseConditionRow}>
|
||||||
<label>Fester Wert (falls keine Referenz)</label>
|
<label>{t('Fester Wert (ohne Referenz)')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={String(staticValue ?? '')}
|
value={String(staticValue ?? '')}
|
||||||
onChange={(e) => handleStaticValueChange(e.target.value)}
|
onChange={(e) => handleStaticValueChange(e.target.value)}
|
||||||
placeholder="z.B. CH oder 42"
|
placeholder={t('z. B. CH oder 42')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.ifElseConditionRow}>
|
<div className={styles.ifElseConditionRow}>
|
||||||
<label>Fälle (Reihenfolge = Ausgang)</label>
|
<label>{t('Fälle / Reihenfolge / Ausgabe')}</label>
|
||||||
<div className={styles.formFieldsList}>
|
<div className={styles.formFieldsList}>
|
||||||
{cases.map((c, i) => {
|
{cases.map((c, i) => {
|
||||||
const opDef = operators.find((o) => o.value === c.operator) ?? operators[0];
|
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}>
|
<button type="button" className={styles.startsAddBtn} onClick={addCase}>
|
||||||
+ Fall
|
{t('+ Fall')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue