Compare commits

...

110 commits

Author SHA1 Message Date
5f47dd395c Merge branch 'int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
2026-06-09 23:55:04 +02:00
5109279ebd fix: resolve TypeScript build errors
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m31s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:40:43 +02:00
6520763736 automation fixautomation fixeses
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 56s
2026-06-09 22:59:45 +02:00
7eb305f910 cp adapted to 2026 poweron
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 56s
2026-06-09 09:53:38 +02:00
a13a158c67 cleaned servicebag and removed servicehub
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 51s
2026-06-08 23:35:38 +02:00
cd14babb2e refactory workflowAutomation completed as system component reolacing automation2 and graphEditor
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 53s
2026-06-08 10:31:24 +02:00
d398907edc before refactory workflowAutomation
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-07 22:26:22 +02:00
49c3cf7290 fix(mobile): compact FormGenerator header on connector page
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m34s
Reduce page padding, hide subtitle and filters on mobile, keep pagination horizontal. Prevents controls from consuming all viewport height on small screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 08:04:03 +02:00
19a39bc443 Merge branch 'int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
2026-06-04 23:57:19 +02:00
7a914ce2d9 fix: resolve datasource labels on reload using backend labels
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m19s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 23:56:58 +02:00
b21fa78665 Merge pull request 'int' (#5) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 50s
Reviewed-on: #5
2026-06-04 19:42:27 +00:00
78457a7d27 Merge branch 'int' of ssh://git.poweron.swiss:2222/PowerOn/ui-nyla into int
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-04 21:38:45 +02:00
31ed78e863 fix infomaniak 2026-06-04 21:38:42 +02:00
Stephan Schellworth
059bbe956a fix(flow-editor): encode connection id in browse and ClickUp API paths
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m17s
URL-encode connection references with spaces/colons so ClickUp list browse resolves correctly. Surface browse error field in formatApiError.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:24:04 +02:00
Stephan Schellworth
b36b303def feat(flow-editor): add ClickUp hierarchical list picker for clickupList fields
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m21s
Replaces the text-only FolderPicker stub with browse-based list selection, auto-patches teamId on searchTasks, and adds path utility tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:53:49 +02:00
30db1b8316 Merge pull request 'security and mfa' (#4) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
Reviewed-on: #4
2026-06-03 21:25:44 +00:00
4475a45a26 security and mfa
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
2026-06-03 23:21:33 +02:00
5ce871fb3c Merge pull request 'fixes doc generation and renderers' (#3) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 47s
Reviewed-on: #3
2026-06-03 15:03:00 +00:00
59b1e1f6a7 fixes doc generation and renderers
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-03 16:45:22 +02:00
991952dde9 Merge pull request 'int' (#2) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Reviewed-on: #2
2026-06-03 08:27:42 +00:00
7876a528f5 fixes deploy
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-03 10:18:03 +02:00
ff68307a39 fixes private model and udb scoping sources
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 55s
2026-06-03 09:35:59 +02:00
beeed79aaa fix: TDZ crash in useSpeechAudioCapture - move statusRef+_setStatusTracked before stop
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:26:40 +02:00
2031c87529 fix: TDZ crash - move voiceStream/buildPromptFromRefs before _handleSend
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Failing after 28s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:23:31 +02:00
e94420dfe9 fix: UDB icons more compact (15px), remove maxWidth limit (450->800), tighter row gap
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Failing after 30s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 53s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:11:26 +02:00
aa982680fa fix: STT recording lifecycle - stop on send, sync voiceActive with hook status, fix mobile re-record
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Failing after 29s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:43:03 +02:00
e727996a18 Merge remote-tracking branch 'origin/int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
2026-06-01 00:01:47 +02:00
5711450606 fix: UDB compact layout, mobile table view, DataSource ID attach
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 00:00:38 +02:00
Ida
76f35a7f22 feat: visible progress during file upload
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
2026-05-29 07:06:30 +02:00
Ida
5aacf17b13 feat: Filter zurücksetzen button 2026-05-29 07:06:30 +02:00
Ida
ece5f17e2a feat: New Chat, moved new chat button, fixed reloading animation to be more user friendly 2026-05-29 07:06:30 +02:00
Ida
8e67efa092 feat: Bot antowrten im Vollbild 2026-05-29 07:06:30 +02:00
Ida
9d081e8819 fix: node inhalt extrahieren nimmt jetzt context, files page formgenerator und folder tree zeigen jetzt die gleichen elemente 2026-05-29 07:06:30 +02:00
Ida
1c539076e5 fix: canvas loop bug and node placement 2026-05-29 07:06:30 +02:00
7da7ad5041 auto-refresh teamsbot session list every 10s to catch new/changed sessions
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 45s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:54:29 +02:00
6c319a4170 make AdminDatabaseHealthPage persistent via keepAlive
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:17:15 +02:00
bf4916b447 add sendToChat button to SourcesTab for mobile usage
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m28s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:13:47 +02:00
ab5ead3416 fix db import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 45s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m30s
2026-05-28 11:25:24 +02:00
275b5125c1 ui progress db import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
2026-05-28 09:12:24 +02:00
57319507bb streaming export with log
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-27 23:05:00 +02:00
f27bfd2221 db-export streaming
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
2026-05-27 19:37:37 +02:00
8f9d233d8c fixed db export
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 45s
2026-05-27 18:07:04 +02:00
5a5d24bbe2 fix db sync
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
2026-05-27 17:43:53 +02:00
639cac2e33 fixes udb
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 2s
2026-05-27 16:48:52 +02:00
0331a59da3 config fix
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 17:35:52 +02:00
3cc2f4decf db fixed import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 16:30:35 +02:00
12868fdd17 Pydantic FK als Single Source of Truth
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 15:14:09 +02:00
50c05e91d7 db backup-restore with fk
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 14:33:50 +02:00
036e6a38db fixed db stream upload
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 43s
2026-05-24 14:59:05 +02:00
8d24d57719 fixed db download
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
2026-05-24 08:59:46 +02:00
554d798ae2 dbsync
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-24 08:15:06 +02:00
9047304934 fix: use printf for SSH key to preserve trailing newline
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:22:40 +02:00
7a228f0181 fix: rewrite workflows for Infomaniak SSH deploy, fix API URLs
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:03:47 +02:00
a7921d409e fix: use full GitHub URLs for Azure actions (not mirrored on Forgejo)
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:42:29 +02:00
077dbca759 fix: add env file cleanup step to ui-nyla workflows
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:40:42 +02:00
6dbf91afb2 refactor: migrate to Forgejo workflows, normalize env file names, remove GitHub Actions
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:34:21 +02:00
f35e22c7f4 Sync: full codebase from GitHub frontend_nyla main
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 23:54:30 +02:00
Ida
234ffa7896 updated deployment file and env file
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m33s
2026-05-22 10:24:04 +02:00
Ida
86a3ac647c fix:wrong base url
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-22 07:53:36 +02:00
Ida
0f1f9781b7 fix: wrong backend on porta instance
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Has been cancelled
2026-05-22 07:52:06 +02:00
Ida
1a4f18392c sudo berechtigung zur rsync installation wieder entfernt
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-21 08:55:29 +02:00
Ida
9d18e743bc sudo berechtigung zur rsync installation
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m41s
2026-05-21 08:47:08 +02:00
Ida
638f18cd55 rsync vor deployment installieren
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m43s
2026-05-21 08:38:02 +02:00
Ida
f1234fedb3 updated deployment workflow to forgejo
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 6m11s
2026-05-21 08:29:02 +02:00
Patrick Motsch
4f2745cc2e
Merge pull request #86 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 2s
Int
2026-05-19 22:35:29 +02:00
ValueOn AG
1308e6d415 fixes rag and workflow 2026-05-19 16:47:52 +02:00
ValueOn AG
65170d9e4c fixed toggle icons udb 2026-05-18 07:57:02 +02:00
ValueOn AG
f37774ff36 db connection pooling and rag limit transparency
Some checks failed
Deploy Nyla Frontend to Integration / build-and-deploy (push) Failing after 17s
2026-05-17 20:38:40 +02:00
Patrick Motsch
ca6261fb1a
Merge pull request #85 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 3s
Int
2026-05-17 00:10:06 +02:00
ValueOn AG
bb441f5268 fixed admin consent msft 2026-05-17 00:07:54 +02:00
Patrick Motsch
e1260c173c
Merge pull request #84 from valueonag/feat/demo-system-readieness
rag enhancements
2026-05-16 23:02:40 +02:00
ValueOn AG
12e10350d9 rag enhancements 2026-05-16 22:55:48 +02:00
Patrick Motsch
de8007644f
Merge pull request #83 from valueonag/feat/demo-system-readieness
upgr node24
2026-05-12 23:46:54 +02:00
ValueOn AG
abdb499067 upgr node24 2026-05-12 23:44:35 +02:00
Patrick Motsch
629333f910
Merge pull request #82 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-12 23:41:03 +02:00
ValueOn AG
8c0e2ee8af build fixes 2026-05-12 23:40:28 +02:00
ValueOn AG
0e89ed2a64 fixes stt paras 2026-05-12 23:33:44 +02:00
ValueOn AG
ba5b0fa8e8 fixes ui 2026-05-12 22:44:20 +02:00
ValueOn AG
230055a4fb teamsbot ux fixes 2026-05-12 22:39:45 +02:00
ValueOn AG
0d8e6501d3 teamsbot auth fixes 2026-05-12 21:31:27 +02:00
ValueOn AG
2ee08c314b teamsbot anonymous bot working 2026-05-12 19:16:28 +02:00
ValueOn AG
ccb2798170 fixed teams 2026-05-12 17:49:44 +02:00
ValueOn AG
a6b37ed684 rag 2026-05-12 15:19:07 +02:00
ValueOn AG
791d575b7d fixed teamsbot issues 2026-05-11 23:59:27 +02:00
ValueOn AG
544f36460a google keys transferred to account poweron.center.ai 2026-05-11 21:26:24 +02:00
Patrick Motsch
5c55312c60
Merge pull request #81 from valueonag/int
Int
2026-05-10 22:24:28 +02:00
Patrick Motsch
9b6edec74e
Merge pull request #80 from valueonag/int
Int
2026-05-10 22:24:04 +02:00
idittrich-valueon
956a226b1b
Merge pull request #75 from valueonag/int
Int
2026-05-04 14:09:37 +02:00
Ida
4356394fd8 forgejo setup 2026-04-21 10:35:36 +02:00
Ida
02e7701329 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
1c6f1ac435 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
98a14a5394 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
bcc632927d trigger deploy 2026-04-21 10:35:36 +02:00
Ida
c5b27c0fbd trigger deploy 2026-04-21 10:35:36 +02:00
Ida
a9bdb2d4d4 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
893326e51d add forgejo deploy workflow 2026-04-21 10:35:36 +02:00
Patrick Motsch
9e872910f4
Merge pull request #52 from valueonag/int
Int
2026-04-21 07:48:36 +02:00
Patrick Motsch
3997c6ec63
Merge pull request #50 from valueonag/int
Int
2026-04-21 00:57:41 +02:00
Patrick Motsch
7c35c7117b
Merge pull request #47 from valueonag/int
Int
2026-04-20 19:11:14 +02:00
Patrick Motsch
13af1dbb05
Merge pull request #45 from valueonag/int
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Has been cancelled
Int
2026-04-19 01:39:32 +02:00
Patrick Motsch
bbd78696e6
Merge pull request #42 from valueonag/int
Int
2026-04-17 21:54:34 +02:00
Patrick Motsch
0178de9650
Merge pull request #34 from valueonag/int
Some checks are pending
Deploy Nyla Frontend to Production / build-and-deploy (push) Waiting to run
Int
2026-04-14 16:28:18 +02:00
Patrick Motsch
a6241fb296
Merge pull request #32 from valueonag/int
Int
2026-04-14 13:32:25 +02:00
Patrick Motsch
51cad2cab6
Merge pull request #28 from valueonag/int
core class for system attributes sysCreated / sysModified
2026-04-04 19:15:21 +02:00
Patrick Motsch
ccb6da36f0
Merge pull request #27 from valueonag/int
Int
2026-04-04 16:21:11 +02:00
Patrick Motsch
e1d06e2a9d
Merge pull request #24 from valueonag/int
Int
2026-03-23 10:39:04 +01:00
Patrick Motsch
2ade186821
Merge pull request #22 from valueonag/int
fixed rendering issues
2026-03-22 11:11:45 +01:00
Patrick Motsch
708687a5e4
Merge pull request #21 from valueonag/int
Int
2026-03-22 01:28:21 +01:00
Patrick Motsch
197bc51632
Merge pull request #20 from valueonag/int
Int
2026-03-19 13:44:20 +01:00
Patrick Motsch
d3c3a5d465
Merge pull request #15 from valueonag/int
fix feature instance passing
2026-03-13 08:31:28 +01:00
526 changed files with 20001 additions and 31520 deletions

View file

@ -1,144 +0,0 @@
# Implement RBAC Roles Page
## Overview
Implement the RBAC roles admin page following the exact pattern used in `mandates.ts`. This includes creating the API file, custom hook for state management, updating the page configuration with CreateButton header button, and adding translations in all three languages (German, English, French).
## Files to Create/Modify
### 1. Create API File: `frontend_nyla/src/api/roleApi.ts`
- Follow the pattern from `mandateApi.ts`
- Implement all required endpoints:
- `fetchRoles()` - GET /api/rbac/roles (with pagination support)
- `fetchRoleById()` - GET /api/rbac/roles/{roleId}
- `fetchRoleOptions()` - GET /api/rbac/roles/options
- `createRole()` - POST /api/rbac/roles
- `updateRole()` - PUT /api/rbac/roles/{roleId}
- `deleteRole()` - DELETE /api/rbac/roles/{roleId}
- Include TypeScript types: `Role`, `RoleUpdateData`, `PaginationParams`, `PaginatedResponse`
### 2. Create Hook: `frontend_nyla/src/hooks/useAdminRbacRoles.ts`
- Follow the exact pattern from `useAdminMandates.ts`
- Create two hooks:
- `useRbacRoles()` - Main hook for data fetching and state management
- Fetch roles with pagination support
- Fetch attributes from `/api/attributes/Role` using `fetchAttributes(request, 'Role')`
- Fetch permissions using `checkPermission('DATA', 'Role')`
- Implement `generateEditFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `generateCreateFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `ensureAttributesLoaded()` for EditActionButton
- Implement optimistic updates (`removeOptimistically`, `updateOptimistically`)
- Return pagination info, attributes, permissions, and all required functions
- `useRbacRoleOperations()` - Operations hook for CRUD
- `handleRoleDelete()` - Delete with loading state tracking
- `handleRoleCreate()` - Create with error handling
- `handleRoleUpdate()` - Update with error handling
- Track loading states in Sets (deletingRoles, editingRoles, creatingRole)
- Return error states (deleteError, createError, updateError)
### 3. Update Page Configuration: `frontend_nyla/src/core/PageManager/data/pages/admin/rbac-role.ts`
- Follow the exact structure from `mandates.ts`
- Import `FaPlus` from `react-icons/fa` for the create button icon
- Create `createRbacRolesHook()` factory function that:
- Uses `useRbacRoles()` and `useRbacRoleOperations()`
- Converts attributes to columns using `attributesToColumns()` helper
- Implements `handleDeleteSingle` and `handleDeleteMultiple` callbacks
- Returns all required data for FormGeneratorTable
- Update `rbacRolePageData`:
- Add header button with `FaPlus` icon for creating roles (following mandates.ts pattern):
```typescript
headerButtons: [
{
id: 'add-role',
label: 'admin.rbac-role.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'admin.rbac-role.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleRoleCreate',
successMessage: 'admin.rbac-role.create.success',
errorMessage: 'admin.rbac-role.create.error'
}
}
]
```
- Add table content section with:
- `hookFactory: createRbacRolesHook`
- Action buttons: edit and delete (following mandates pattern)
- Configure edit button with `fetchItemFunctionName: 'fetchRoleById'`
- Configure delete button with proper operation names
- Add permission-based disabled logic
- Keep existing privilege checker (sysadmin only)
### 4. Update Translations: All three locale files
- **German (`frontend_nyla/src/locales/de.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Rolle hinzufügen'`
- `'admin.rbac-role.action.edit': 'Bearbeiten'`
- `'admin.rbac-role.action.delete': 'Löschen'`
- `'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen'`
- `'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt'`
- `'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle'`
- **English (`frontend_nyla/src/locales/en.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Add Role'`
- `'admin.rbac-role.action.edit': 'Edit'`
- `'admin.rbac-role.action.delete': 'Delete'`
- `'admin.rbac-role.modal.create.title': 'Create New Role'`
- `'admin.rbac-role.create.success': 'Role created successfully'`
- `'admin.rbac-role.create.error': 'Error creating role'`
- **French (`frontend_nyla/src/locales/fr.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Ajouter un rôle'`
- `'admin.rbac-role.action.edit': 'Modifier'`
- `'admin.rbac-role.action.delete': 'Supprimer'`
- `'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle'`
- `'admin.rbac-role.create.success': 'Rôle créé avec succès'`
- `'admin.rbac-role.create.error': 'Erreur lors de la création du rôle'`
## Implementation Details
### API File Structure
- Use `ApiRequestFunction` type from `useApi`
- Support pagination parameters (page, pageSize, sort, filters, search)
- Handle both paginated and non-paginated responses
- Use `/api/rbac/roles` as base URL
- Use `/api/attributes/Role` for attributes endpoint
### Hook Pattern
- Use `useApiRequest` hook for API calls
- Use `usePermissions` hook for permission checking
- Use `getUserDataCache()` to check authentication before fetching
- Implement attribute type mapping using utilities from `attributeTypeMapper.ts`:
- `isCheckboxType()`, `isSelectType()`, `isMultiselectType()`, `isDateTimeType()`, `isTextareaType()`
- Filter out non-editable fields (id, readonly fields, etc.)
- Handle options arrays and option references
### Page Configuration Pattern
- Use `attributesToColumns()` helper to convert attributes to column config
- Disable filtering for date/timestamp fields using `isDateTimeType()`
- Configure action buttons with proper field mappings and operation names
- Use permission-based disabled logic for buttons
- Set `entityType: 'Role'` for EditActionButton
- Add header button using CreateButton component pattern (via formConfig in headerButtons)
## Key Dependencies
- `useApiRequest` from `hooks/useApi`
- `usePermissions` from `hooks/usePermissions`
- `fetchAttributes` from `api/attributesApi`
- `attributeTypeMapper` utilities from `utils/attributeTypeMapper`
- `FormGeneratorTable` component
- `EditActionButton` and `DeleteActionButton` components
- `CreateButton` component (rendered via PageRenderer from headerButtons formConfig)
- `FaPlus` icon from `react-icons/fa`
## Testing Considerations
- Verify all API endpoints are called correctly
- Ensure attributes are fetched from `/api/attributes/Role`
- Verify permission checks work correctly
- Test create, edit, delete operations
- Verify optimistic updates work
- Check that date/timestamp fields are not filterable
- Verify CreateButton appears in header and opens create modal
- Verify translations work in all three languages

View file

@ -0,0 +1,53 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta-int.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin int
git reset --hard origin/int
cp config/env-int.env .env
rm -f config/env-*.env
npm ci
npm run build:int
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

View file

@ -0,0 +1,53 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin main
git reset --hard origin/main
cp config/env-prod.env .env
rm -f config/env-*.env
npm ci
npm run build:prod
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

View file

@ -1,71 +0,0 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy integration environment file
run: |
cp config/env-poweron-nyla-int.env .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for integration
run: npm run build:int
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend-int",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla-int'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA_INT }}
package: ./deploy

View file

@ -1,71 +0,0 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy production environment file
run: |
cp config/env-poweron-nyla-prod.env .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for production
run: npm run build:prod
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA }}
package: ./deploy

5
.gitignore vendored
View file

@ -31,4 +31,7 @@ dist-ssr
.cursorignore .cursorignore
# Keep environment files in config/ (naming: env-<workflow>.env) # Keep environment files in config/ (naming: env-<workflow>.env)
!config/env-*.env !config/env-*.env
tsc-errors.txt
scripts/i18n_missing_report.md

View file

@ -1,178 +1,24 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Simple Configuration Service * Configuration reads mandatory env vars set by .env (copied from config/env-*.env by CI).
* Centralized access to environment variables with fallbacks *
* NO silent fallbacks for critical values.
* If VITE_API_BASE_URL is missing the app fails loudly at startup.
*
* Vite replaces import.meta.env.VITE_* statically at build time.
* Dynamic access via import.meta.env[key] does NOT work in production builds.
* Therefore each variable must be accessed with its literal property name.
*/ */
// API Configuration const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
};
export const getApiTimeout = (): number => { if (!_apiBaseUrl) {
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000'); throw new Error(
}; 'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
);
}
// App Configuration export const getApiBaseUrl = (): string => _apiBaseUrl;
export const getAppName = (): string => {
return import.meta.env.VITE_APP_NAME || 'PowerOn';
};
export const getAppVersion = (): string => { export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';
return import.meta.env.VITE_APP_VERSION || '0.0.0';
};
export const getAppEnvironment = (): string => {
return import.meta.env.VITE_APP_ENVIRONMENT || 'dev';
};
// Environment Detection
export const isDevelopment = (): boolean => {
return import.meta.env.MODE === 'development' || getAppEnvironment() === 'dev';
};
export const isProduction = (): boolean => {
return import.meta.env.MODE === 'production' || getAppEnvironment() === 'prod';
};
export const isIntegration = (): boolean => {
return getAppEnvironment() === 'int';
};
// Debug Configuration
export const isDebugMode = (): boolean => {
return import.meta.env.VITE_DEBUG === 'true';
};
export const getLogLevel = (): string => {
return import.meta.env.VITE_LOG_LEVEL || 'info';
};
export const isConsoleLogsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CONSOLE_LOGS === 'true';
};
// Microsoft Authentication
export const getMicrosoftClientId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_CLIENT_ID;
};
export const getMicrosoftTenantId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_TENANT_ID;
};
export const getEntraClientSecret = (): string | undefined => {
return import.meta.env.VITE_ENTRA_CLIENT_SECRET;
};
export const getEntraAuthority = (): string | undefined => {
return import.meta.env.VITE_ENTRA_AUTHORITY;
};
export const getEntraRedirectPath = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_PATH;
};
export const getEntraRedirectUri = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_URI;
};
// Feature Flags (if needed in the future)
export const isFeatureEnabled = (feature: string): boolean => {
const envKey = `VITE_ENABLE_${feature.toUpperCase()}`;
return import.meta.env[envKey] === 'true';
};
// Analytics and Monitoring
export const isAnalyticsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ANALYTICS === 'true';
};
export const isErrorReportingEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true';
};
export const isPerformanceMonitoringEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_PERFORMANCE_MONITORING === 'true';
};
// Development Server (for dev environment)
export const getDevServerPort = (): number => {
return parseInt(import.meta.env.VITE_DEV_SERVER_PORT || '5176');
};
export const getDevServerHost = (): string => {
return import.meta.env.VITE_DEV_SERVER_HOST || 'localhost';
};
export const isDevServerHttps = (): boolean => {
return import.meta.env.VITE_DEV_SERVER_HTTPS === 'true';
};
// Security Configuration
export const isHttpsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_HTTPS === 'true';
};
export const isCspEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CSP === 'true';
};
// Test Configuration
export const isMockDataEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_MOCK_DATA === 'true';
};
export const isTestMode = (): boolean => {
return import.meta.env.VITE_ENABLE_TEST_MODE === 'true';
};
// Convenience object for easy destructuring
export const config = {
// API
getApiBaseUrl,
getApiTimeout,
// App
getAppName,
getAppVersion,
getAppEnvironment,
// Environment
isDevelopment,
isProduction,
isIntegration,
// Debug
isDebugMode,
getLogLevel,
isConsoleLogsEnabled,
// Microsoft Auth
getMicrosoftClientId,
getMicrosoftTenantId,
getEntraClientSecret,
getEntraAuthority,
getEntraRedirectPath,
getEntraRedirectUri,
// Features
isFeatureEnabled,
// Analytics
isAnalyticsEnabled,
isErrorReportingEnabled,
isPerformanceMonitoringEnabled,
// Dev Server
getDevServerPort,
getDevServerHost,
isDevServerHttps,
// Security
isHttpsEnabled,
isCspEnabled,
// Test
isMockDataEnabled,
isTestMode,
};

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName) # Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env. # Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL="http://localhost:8000/" VITE_API_BASE_URL="http://localhost:8000"
VITE_APP_NAME=PowerOn Nyla dev VITE_APP_NAME=PowerOn Nyla dev

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName) # Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env. # Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://gateway-int.poweron.swiss VITE_API_BASE_URL=https://api-int.poweron.swiss
VITE_APP_NAME=Poweron Nyla int VITE_APP_NAME=Poweron Nyla int

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName) # Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env. # Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://gateway-prod.poweron.swiss VITE_API_BASE_URL=https://api.poweron.swiss
VITE_APP_NAME=PowerOn Nyla VITE_APP_NAME=PowerOn Nyla

View file

@ -1,12 +1,3 @@
// Export simple configuration service // Copyright (c) 2026 PowerOn AG
export * from './config'; // All rights reserved.
export { getApiBaseUrl, getAppName } from './config';
// Re-export commonly used functions
export {
getApiBaseUrl,
getAppName,
isDevelopment,
isProduction,
isDebugMode,
config
} from './config';

2
env.d.ts vendored
View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {

View file

@ -185,7 +185,10 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p> <p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company &amp; legal details &middot; May 2026</p>
<p><strong>PowerOn AG</strong> · Birmensdorferstrasse 94 · CH-8003 Zürich · Switzerland</p>
<p><a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p style="margin-top: 1rem;">&copy; 2026 PowerOn. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View file

@ -140,7 +140,7 @@
</div> </div>
<div class="last-updated"> <div class="last-updated">
<strong>Last Updated:</strong> August 2025 <strong>Last Updated:</strong> May 2026
</div> </div>
<div class="content-section"> <div class="content-section">
@ -272,8 +272,13 @@
<h2>Contact Us</h2> <h2>Contact Us</h2>
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p> <p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
<div class="highlight-box"> <div class="highlight-box">
<p><strong>Email:</strong> privacy@poweron-ai.com</p> <p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p> <p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div> </div>
</div> </div>
@ -283,7 +288,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p> <p>&copy; 2026 PowerOn. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View file

@ -153,7 +153,7 @@
</div> </div>
<div class="last-updated"> <div class="last-updated">
<strong>Last Updated:</strong> August 2025 <strong>Last Updated:</strong> May 2026
</div> </div>
<div class="content-section"> <div class="content-section">
@ -315,8 +315,13 @@
<h2>Contact Information</h2> <h2>Contact Information</h2>
<p>If you have any questions about these Terms of Service, please contact us:</p> <p>If you have any questions about these Terms of Service, please contact us:</p>
<div class="highlight-box"> <div class="highlight-box">
<p><strong>Email:</strong> legal@poweron-ai.com</p> <p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p> <p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div> </div>
</div> </div>
@ -326,7 +331,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p> <p>&copy; 2026 PowerOn. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* App.tsx * App.tsx
* *
@ -39,11 +41,12 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store'; import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; 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, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } 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 { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage';
import { RagInventoryPage } from './pages/RagInventoryPage';
import { ComplianceAuditPage } from './pages/ComplianceAuditPage'; 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
@ -123,15 +126,20 @@ function App() {
</Route> </Route>
{/* ============================================== */} {/* ============================================== */}
{/* AUTOMATIONS DASHBOARD */} {/* WORKFLOW AUTOMATION (System-Komponente) */}
{/* ============================================== */} {/* ============================================== */}
<Route path="automations" element={<AutomationsDashboardPage />} /> <Route path="workflow-automation" element={<WorkflowAutomationPage />} />
{/* ============================================== */}
{/* RAG INVENTORY */}
{/* ============================================== */}
<Route path="rag-inventory" element={<RagInventoryPage />} />
{/* 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="pek" element={<Navigate to="/" replace />} /> <Route path="pek" element={<Navigate to="/" replace />} />
<Route path="speech" element={<Navigate to="/" replace />} /> <Route path="speech" element={<Navigate to="/" replace />} />
{/* ============================================== */} {/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */} {/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */} {/* /mandates/:mandateId/:featureCode/:instanceId */}
@ -165,13 +173,8 @@ function App() {
<Route path="templates" element={<FeatureViewPage view="templates" />} /> <Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} /> <Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Workspace + Automation2 Editor */} {/* Workspace Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} /> <Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */} {/* Teams Bot Feature Views */}
<Route path="sessions" element={<FeatureViewPage view="sessions" />} /> <Route path="sessions" element={<FeatureViewPage view="sessions" />} />
@ -218,7 +221,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} /> <Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} /> <Route path="languages" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} /> <Route path="database-health" element={null} />
<Route path="demo-config" element={<AdminDemoConfigPage />} /> <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 />} />

View file

@ -1,25 +1,10 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
// 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, getUserDataCache } from './utils/userCache'; import { clearUserDataCache, getUserDataCache } from './utils/userCache';
// Utility function to resolve hostname to IP address
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
try {
// For localhost, return as is
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return hostname;
}
// For production domains, we can't directly resolve IP due to CORS
// But we can show the hostname which is more useful anyway
return hostname;
} catch (error) {
console.warn('Could not resolve hostname to IP:', error);
return hostname;
}
};
/** /**
* Extract mandate/instance context from current URL. * Extract mandate/instance context from current URL.
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/... * URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
@ -44,52 +29,25 @@ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
import { getApiBaseUrl } from '../config/config'; import { getApiBaseUrl } from '../config/config';
const _baseUrl = getApiBaseUrl();
if (import.meta.env.DEV) {
console.log(`[api] Backend: ${_baseUrl} | env: ${import.meta.env.MODE}`);
}
const api = axios.create({ const api = axios.create({
baseURL: getApiBaseUrl(), baseURL: _baseUrl,
withCredentials: true, withCredentials: true,
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
// endpoint never reach the route. Setting ``indexes: null`` switches
// the URLSearchParams visitor to repeat format. Applies globally so
// every endpoint with array query params gets it for free.
paramsSerializer: { indexes: null }, paramsSerializer: { indexes: null },
}); });
// Add a request interceptor to add the auth token, context headers, and log backend IP // Add a request interceptor to add the auth token, context headers
api.interceptors.request.use( api.interceptors.request.use(
async (config) => { async (config) => {
// Log backend information // Add auth token if available (otherwise httpOnly cookies are used automatically)
const backendUrl = config.baseURL || getApiBaseUrl();
console.log(`🌐 Communicating with backend: ${backendUrl}`);
// Try to resolve and log the IP address
if (backendUrl) {
try {
const url = new URL(backendUrl);
const hostname = url.hostname;
const resolvedIP = await resolveHostnameToIP(hostname);
console.log(`📍 Backend hostname: ${hostname}`);
console.log(`🔗 Full backend URL: ${backendUrl}`);
console.log(`🌍 Resolved address: ${resolvedIP}`);
// Log environment info
console.log(`🏗️ Environment: ${import.meta.env.MODE}`);
console.log(`⚙️ API Base URL: ${getApiBaseUrl()}`);
} catch (error) {
console.warn('Could not parse backend URL:', error);
}
}
// Check for auth token in localStorage and add to headers
const authToken = localStorage.getItem('authToken'); const authToken = localStorage.getItem('authToken');
if (authToken && config.headers) { if (authToken && config.headers) {
config.headers.Authorization = `Bearer ${authToken}`; config.headers.Authorization = `Bearer ${authToken}`;
console.log('🔑 Using Bearer token for authentication');
} else {
// Fallback: httpOnly cookies
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
} }
// Send app language to backend so i18n labels match the UI // Send app language to backend so i18n labels match the UI

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
import type { AttributeType } from '../utils/attributeTypeMapper'; import type { AttributeType } from '../utils/attributeTypeMapper';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
import api from '../api'; import api from '../api';
import { addCSRFTokenToHeaders } from '../utils/csrfUtils'; import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
@ -12,14 +14,30 @@ export interface LoginRequest {
} }
export interface LoginResponse { export interface LoginResponse {
type: 'local_auth_success'; type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required';
accessToken?: string; accessToken?: string;
tokenType?: string; tokenType?: string;
authenticationAuthority?: string; authenticationAuthority?: string;
mfaToken?: string;
provisioningUri?: string;
label?: any; label?: any;
fieldLabels?: any; fieldLabels?: any;
} }
export interface MfaVerifyRequest {
token: string;
code: string;
}
export interface MfaSetupResponse {
provisioningUri: string;
}
export interface MfaStatusResponse {
mfaEnabled: boolean;
mfaRequired: boolean;
}
export interface RegisterData { export interface RegisterData {
username: string; username: string;
email: string; email: string;
@ -316,3 +334,36 @@ export async function logoutApi(): Promise<void> {
await api.post('/api/local/logout'); await api.post('/api/local/logout');
} }
// ============================================================================
// MFA API FUNCTIONS
// ============================================================================
export async function mfaVerifyApi(data: MfaVerifyRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/api/mfa/verify', data);
return response.data;
}
export async function mfaSetupApi(): Promise<MfaSetupResponse> {
const response = await api.post<MfaSetupResponse>('/api/mfa/setup');
return response.data;
}
export async function mfaConfirmApi(code: string, token?: string): Promise<{ mfaEnabled: boolean }> {
if (token) {
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm', { code, token });
return response.data;
}
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm-authenticated', { code });
return response.data;
}
export async function mfaStatusApi(): Promise<MfaStatusResponse> {
const response = await api.get<MfaStatusResponse>('/api/mfa/status');
return response.data;
}
export async function mfaDisableApi(code: string): Promise<{ mfaEnabled: boolean }> {
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/disable', { code });
return response.data;
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,329 +0,0 @@
import { ApiRequestOptions } from '../hooks/useApi';
import api from '../api';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface UserInputRequest {
input: string;
workflowId?: string;
files?: Array<{ id: string; name: string }>;
metadata?: Record<string, any>;
}
export interface ChatbotWorkflow {
id: string;
mandateId?: string; // Optional - not in ChatbotConversation
featureInstanceId?: string; // From ChatbotConversation
status: string;
name?: string;
currentRound?: number;
currentTask?: number;
currentAction?: number;
startedAt?: number;
lastActivity?: number;
[key: string]: any;
}
export interface StartChatbotRequest {
prompt: string;
listFileId?: string[];
userLanguage?: string;
workflowId?: string;
metadata?: Record<string, any>;
}
export interface StartChatbotResponse extends ChatbotWorkflow {
// Workflow object returned from start endpoint
}
export interface ChatDataItem {
type: 'message' | 'log' | 'stat' | 'document' | 'stopped' | 'status' | 'chunk';
createdAt?: number;
item?: Message | any;
label?: string; // For status events
content?: string; // For chunk events (token-by-token streaming)
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// Type for SSE event handler
export type SSEEventHandler = (item: ChatDataItem) => void;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Start a new chatbot workflow or continue an existing one with SSE streaming
* Endpoint: POST /api/chatbot/{instanceId}/start/stream
*
* @param instanceId - Feature Instance ID
* @param requestBody - Request body with prompt and optional workflowId
* @param onEvent - Callback function called for each SSE event
* @param onError - Optional error callback
* @param onComplete - Optional completion callback
* @returns Promise that resolves when stream completes
*/
export async function startChatbotStreamApi(
instanceId: string,
requestBody: StartChatbotRequest,
onEvent: SSEEventHandler,
onError?: (error: Error) => void,
onComplete?: () => void
): Promise<void> {
try {
// Prepare request body
console.log('[startChatbotStreamApi] instanceId:', instanceId);
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
const body: any = {
prompt: requestBody.prompt,
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
...(requestBody.userLanguage && { userLanguage: requestBody.userLanguage }),
...(requestBody.metadata && { metadata: requestBody.metadata })
};
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
// Add workflowId to query params if provided
const url = requestBody.workflowId
? `/api/chatbot/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`
: `/api/chatbot/${instanceId}/start/stream`;
// Get base URL from api instance
const baseURL = api.defaults.baseURL || '';
const fullURL = baseURL + url;
// Prepare headers with authentication and CSRF token
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add auth token if available
const authToken = localStorage.getItem('authToken');
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
// Add CSRF token for POST requests
if (!getCSRFToken()) {
generateAndStoreCSRFToken();
}
addCSRFTokenToHeaders(headers);
// Use fetch for SSE streaming (POST with body)
const response = await fetch(fullURL, {
method: 'POST',
headers,
body: JSON.stringify(body),
credentials: 'include' // Include cookies for authentication
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Decode chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete SSE messages
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6); // Remove 'data: ' prefix
if (jsonStr.trim()) {
const item: ChatDataItem = JSON.parse(jsonStr);
console.log('[SSE] Received event:', item.type, item);
onEvent(item);
}
} catch (parseError) {
console.warn('Failed to parse SSE event:', line, parseError);
}
} else if (line.startsWith(':')) {
// Comment/keepalive line, ignore
continue;
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6);
if (jsonStr.trim()) {
const item: ChatDataItem = JSON.parse(jsonStr);
onEvent(item);
}
} catch (parseError) {
console.warn('Failed to parse SSE event:', line, parseError);
}
}
}
}
if (onComplete) {
onComplete();
}
} finally {
reader.releaseLock();
}
} catch (error: any) {
console.error('Error in startChatbotStreamApi:', error);
if (onError) {
onError(error instanceof Error ? error : new Error(String(error)));
} else {
throw error;
}
}
}
/**
* Stop a running chatbot workflow
* Endpoint: POST /api/chatbot/{instanceId}/stop/{workflowId}
*/
export async function stopChatbotApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<ChatbotWorkflow> {
console.log('[stopChatbotApi] Calling stop endpoint:', `/api/chatbot/${instanceId}/stop/${workflowId}`, { instanceId, workflowId });
const data = await request({
url: `/api/chatbot/${instanceId}/stop/${workflowId}`,
method: 'post'
});
console.log('[stopChatbotApi] Stop response:', data);
return data as ChatbotWorkflow;
}
/**
* Get chatbot threads/workflows
* Endpoint: GET /api/chatbot/{instanceId}/threads
*/
export async function getChatbotThreadsApi(
request: ApiRequestFunction,
instanceId: string,
pagination?: { page?: number; pageSize?: number }
): Promise<{ items: ChatbotWorkflow[]; metadata: any }> {
const paginationParam = pagination ? JSON.stringify(pagination) : undefined;
const requestParams = paginationParam
? { pagination: paginationParam }
: undefined;
console.log(`[getChatbotThreadsApi] instanceId: ${instanceId}, params:`, requestParams);
const data = await request({
url: `/api/chatbot/${instanceId}/threads`,
method: 'get',
params: requestParams
}) as any;
console.log(`[getChatbotThreadsApi] Full response:`, JSON.stringify(data, null, 2));
console.log(`[getChatbotThreadsApi] Response structure:`, {
hasItems: !!data.items,
itemsLength: Array.isArray(data.items) ? data.items.length : 'not an array',
hasMetadata: !!data.metadata,
metadataKeys: data.metadata ? Object.keys(data.metadata) : []
});
return {
items: Array.isArray(data.items) ? data.items : [],
metadata: data.pagination ?? data.metadata ?? {}
};
}
/**
* Get a specific chatbot thread/workflow with its chat data
* Endpoint: GET /api/chatbot/{instanceId}/threads?workflowId={id}
*
* Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } }
*
* @param request - API request function
* @param instanceId - Feature Instance ID
* @param workflowId - ID of the workflow to fetch
* @returns Object containing workflow details and chatData with items array
*/
export async function getChatbotThreadApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> {
console.log(`[getChatbotThreadApi] instanceId: ${instanceId}, workflowId: ${workflowId}`);
const data = await request({
url: `/api/chatbot/${instanceId}/threads`,
method: 'get',
params: { workflowId }
}) as { workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } };
console.log(`[getChatbotThreadApi] Full response for workflowId ${workflowId}:`, JSON.stringify(data, null, 2));
console.log(`[getChatbotThreadApi] Response structure:`, {
hasWorkflow: !!data.workflow,
workflowKeys: data.workflow ? Object.keys(data.workflow) : [],
hasChatData: !!data.chatData,
hasItems: !!data.chatData?.items,
chatDataKeys: data.chatData ? Object.keys(data.chatData) : [],
itemsLength: Array.isArray(data.chatData?.items) ? data.chatData.items.length : 'not an array',
chatDataTypes: Array.isArray(data.chatData?.items) ? data.chatData.items.map((item: ChatDataItem) => item?.type).filter(Boolean) : []
});
return {
workflow: data.workflow,
chatData: data.chatData || { items: [] }
};
}
/**
* Delete a chatbot workflow
* Endpoint: DELETE /api/chatbot/{instanceId}/{workflowId}
*
* @param request - API request function
* @param instanceId - Feature Instance ID
* @param workflowId - ID of the workflow to delete
* @returns Success status
*/
export async function deleteChatbotWorkflowApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<boolean> {
try {
await request({
url: `/api/chatbot/${instanceId}/${workflowId}`,
method: 'delete'
});
return true;
} catch (error: any) {
console.error('Error deleting chatbot workflow:', error);
throw error;
}
}

135
src/api/clickupApi.ts Normal file
View file

@ -0,0 +1,135 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* ClickUp API ClickUp-specific functions for the workflow automation flow editor.
*
* Extracted from the legacy workflowApi.ts re-export shim so each integration
* lives in its own module.
*/
import type { ApiRequestFunction } from './workflowAutomationApi';
function _encodedConnectionId(connectionId: string): string {
return encodeURIComponent(connectionId);
}
/** 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/${_encodedConnectionId(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/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(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/${_encodedConnectionId(connectionId)}/teams/${encodeURIComponent(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/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(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/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(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;
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -6,17 +8,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences { export interface KnowledgePreferences {
schemaVersion?: number; schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full'; mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean; mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean; filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments'; clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean; clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number; maxAgeDays?: number;
} }
@ -292,3 +288,210 @@ export async function submitInfomaniakToken(
}); });
} }
// ============================================================================
// RAG KNOWLEDGE CONSENT & CONTROL
// ============================================================================
export async function patchKnowledgeConsent(
request: ApiRequestFunction,
connectionId: string,
enabled: boolean
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled }
});
}
export async function patchKnowledgePreferences(
request: ApiRequestFunction,
connectionId: string,
preferences: KnowledgePreferences
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-preferences`,
method: 'patch',
data: { preferences }
});
}
export async function postKnowledgeStop(
request: ApiRequestFunction,
connectionId: string
): Promise<{ connectionId: string; cancelled: number }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-stop`,
method: 'post'
});
}
export interface RagLimits {
maxItems?: number;
maxBytes?: number;
maxFileSize?: number;
maxDepth?: number;
// ClickUp variant
maxTasks?: number;
maxWorkspaces?: number;
maxListsPerWorkspace?: number;
}
export interface DataSourceSettings {
ragLimits?: RagLimits;
}
export interface CostEstimate {
estimatedTokens: number;
estimatedChf: number;
basis: {
kind: string;
limits: Record<string, number>;
assumptions: Record<string, any>;
notes: string;
};
sourceId?: string;
}
export async function patchDataSourceSettings(
request: ApiRequestFunction,
dataSourceId: string,
settings: DataSourceSettings
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
return await request({
url: `/api/datasources/${dataSourceId}/settings`,
method: 'patch',
data: { settings }
});
}
export async function getDataSourceCostEstimate(
request: ApiRequestFunction,
dataSourceId: string
): Promise<CostEstimate> {
return await request({
url: `/api/datasources/${dataSourceId}/cost-estimate`,
method: 'get'
});
}
// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
// `UdbSourcesProvider` and the wiki UDB reference page.
// ============================================================================
// RAG INVENTORY
// ============================================================================
export interface RagDataSourceDto {
id: string;
label: string;
path: string;
sourceType: string;
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
ragIndexEnabled: boolean | null;
neutralize: boolean | null;
lastIndexed: number | null;
/** Distinct files indexed for this DataSource (one row per source document). */
fileCount: number;
/** Embedding-sized text fragments (one per ContentChunk row, ~400 tokens each). */
chunkCount: number;
}
export interface RagConnectionDto {
id: string;
authority: string;
externalEmail: string;
knowledgeIngestionEnabled: boolean;
preferences: KnowledgePreferences;
dataSources: RagDataSourceDto[];
totalFiles: number;
totalChunks: number;
runningJobs: {
jobId: string;
progress: number;
/** Already translated server-side. */
progressMessage: string;
}[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
skippedPolicy: number;
failed: number;
durationMs: number;
/** Name of the first budget that bit (e.g. "maxBytes", "maxItems", "maxTasks"); null if walk completed naturally. */
stoppedAtLimit?: string | null;
/** Effective limits used by the walker, for showing the value next to the limit name. */
limits?: Record<string, number>;
bytesProcessed?: number;
} | null;
}
export interface RagFeatureDataSourceDto {
id: string;
label: string;
tableName: string;
featureCode: string;
ragIndexEnabled: boolean;
}
export interface RagFeatureInstanceDto {
featureInstanceId: string;
featureCode: string;
label: string;
mandateId: string;
fileCount: number;
chunkCount: number;
statusCounts: Record<string, number>;
dataSources: RagFeatureDataSourceDto[];
ragEnabled: boolean;
runningJobs?: {
jobId: string;
progress: number;
progressMessage: string;
}[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
failed: number;
} | null;
}
export interface RagInventoryDto {
connections: RagConnectionDto[];
featureInstances?: RagFeatureInstanceDto[];
totals: { files: number; chunks: number; bytes?: number };
}
export interface RagActiveJobDto {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
/** Already translated server-side. */
progressMessage: string;
}
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/me', method: 'get' });
}
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
}
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
}
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Features API * Features API
* *
@ -14,8 +16,6 @@ import type {
InstancePermissions, InstancePermissions,
AccessLevel, AccessLevel,
} from '../types/mandate'; } from '../types/mandate';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
// ============================================================================= // =============================================================================
// MOCK DATA (Temporär bis Backend bereit) // MOCK DATA (Temporär bis Backend bereit)
// ============================================================================= // =============================================================================
@ -172,56 +172,11 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
} }
try { try {
console.log('📡 featuresApi: Fetching /api/features/my');
const response = await api.get<FeaturesMyResponse>('/api/features/my'); const response = await api.get<FeaturesMyResponse>('/api/features/my');
// Get the actual data (response.data contains the FeaturesMyResponse) // Get the actual data (response.data contains the FeaturesMyResponse)
const data = response.data; const data = response.data;
// DEBUG: Log all chatbot instances and their permissions
console.log('🔍 [DEBUG] featuresApi: Full response received', {
response,
data,
hasMandates: !!data?.mandates,
mandateCount: data?.mandates?.length || 0,
});
if (data?.mandates) {
data.mandates.forEach(mandate => {
mandate.features.forEach(feature => {
if (feature.code === 'chatbot') {
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
mandateId: mandate.id,
mandateName: mandateDisplayLabel(mandate),
featureCode: feature.code,
instanceCount: feature.instances.length,
});
feature.instances.forEach(instance => {
console.log('🔍 [DEBUG] featuresApi: Chatbot Instance Details:', {
instanceId: instance.id,
instanceLabel: instance.instanceLabel,
featureCode: instance.featureCode,
userRoles: instance.userRoles,
permissions: instance.permissions,
views: instance.permissions?.views,
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] ||
instance.permissions?.views?.['ui.feature.chatbot.conversations'] ||
instance.permissions?.views?.['_all'],
});
});
}
});
});
}
console.log('✅ featuresApi: Loaded features:', {
mandateCount: data?.mandates?.length || 0,
totalInstances: data?.mandates
?.flatMap(m => m.features)
?.flatMap(f => f.instances)
?.length || 0,
});
return data; return data;
} catch (error) { } catch (error) {
console.error('❌ featuresApi: Error fetching features:', error); console.error('❌ featuresApi: Error fetching features:', error);
@ -239,7 +194,6 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
return [ return [
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] }, { code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] }, { code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
]; ];
} }

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -36,6 +38,7 @@ export interface PaginationParams {
search?: string; search?: string;
viewKey?: string; viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -109,6 +112,7 @@ export async function fetchFiles(
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey; if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (params.owner) requestParams.owner = params.owner;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Neutralization API * Neutralization API
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
import type { ApiRequestOptions } from '../hooks/useApi'; import type { ApiRequestOptions } from '../hooks/useApi';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Redmine API * Redmine API
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Store API * Store API
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
export interface TableListViewRow { export interface TableListViewRow {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
import type { VoiceOption } from './voiceCatalogApi'; import type { VoiceOption } from './voiceCatalogApi';
@ -71,6 +73,7 @@ export interface TeamsbotConfig {
triggerCooldownSeconds: number; triggerCooldownSeconds: number;
contextWindowSegments: number; contextWindowSegments: number;
debugMode?: boolean; debugMode?: boolean;
avatarFileId?: string;
} }
export interface TeamsbotSessionStats { export interface TeamsbotSessionStats {
@ -84,6 +87,7 @@ export interface TeamsbotSessionStats {
export interface StartSessionRequest { export interface StartSessionRequest {
meetingLink: string; meetingLink: string;
botName?: string; botName?: string;
moduleId?: string;
connectionId?: string; connectionId?: string;
joinMode?: TeamsbotJoinMode; joinMode?: TeamsbotJoinMode;
sessionContext?: string; sessionContext?: string;
@ -102,6 +106,7 @@ export interface ConfigUpdateRequest {
triggerCooldownSeconds?: number; triggerCooldownSeconds?: number;
contextWindowSegments?: number; contextWindowSegments?: number;
debugMode?: boolean; debugMode?: boolean;
avatarFileId?: string;
} }
// Voice option type re-exported from the central voice catalog API // Voice option type re-exported from the central voice catalog API
@ -462,6 +467,13 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
return new EventSource(url, { withCredentials: true }); return new EventSource(url, { withCredentials: true });
} }
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
export function createDashboardStream(instanceId: string): EventSource {
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
return new EventSource(url, { withCredentials: true });
}
// ========================================================================= // =========================================================================
// Debug Screenshots (SysAdmin only) // Debug Screenshots (SysAdmin only)
// ========================================================================= // =========================================================================
@ -592,6 +604,9 @@ export interface MeetingModule {
defaultDirectorPrompts?: string; defaultDirectorPrompts?: string;
goals?: string; goals?: string;
kpiTargets?: string; kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string; status: string;
} }
@ -602,6 +617,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: { export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string; title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> { }): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body); const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module; return response.data?.module;
@ -620,3 +636,31 @@ export async function updateModule(instanceId: string, moduleId: string, body: P
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> { export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`); await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
} }
export interface MediaFileInfo {
id: string;
fileName: string;
mimeType: string;
}
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
const response = await api.get('/api/files/list', {
params: { pagination: JSON.stringify({ pageSize: 500 }) },
});
const data = response.data;
let items: any[];
if (Array.isArray(data)) {
items = data;
} else if (Array.isArray(data?.items)) {
items = data.items;
} else {
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
items = [];
}
const filtered = items.filter((f: any) => {
const mime = (f.mimeType || '').toLowerCase();
return mime.startsWith('image/') || mime.startsWith('video/');
});
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Trustee API * Trustee API
* *
@ -864,7 +866,14 @@ export async function syncPositionsToAccounting(
request: ApiRequestFunction, request: ApiRequestFunction,
instanceId: string, instanceId: string,
positionIds: string[], positionIds: string[],
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void } opts?: {
pollMs?: number;
/**
* `message` is already translated server-side by the job route handler
* (`resolveJobMessage`). Render it 1:1; never feed it through `t()`.
*/
onProgress?: (progress: number, message?: string | null) => void;
}
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> { ): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
const submission = await request({ const submission = await request({
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`, url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Voice / Language Catalog API. * Voice / Language Catalog API.
* *

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessLevelSelect * AccessLevelSelect
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessRulesEditor * AccessRulesEditor
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessRulesTable * AccessRulesTable
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessRules Components * AccessRules Components
* *

View file

@ -73,13 +73,12 @@
/* Connector grid (Step 0) */ /* Connector grid (Step 0) */
.connectorGrid { .connectorGrid {
display: flex; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.connectorCard { .connectorCard {
flex: 1 1 140px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -447,6 +446,22 @@
cursor: not-allowed; cursor: not-allowed;
} }
.patInput {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9rem;
font-family: monospace;
margin: 12px 0 16px;
}
.patInput:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* Dark theme */ /* Dark theme */
:global(.dark-theme) .connectorCard { :global(.dark-theme) .connectorCard {
background: var(--surface-color); background: var(--surface-color);

View file

@ -1,153 +1,55 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AddConnectionWizard * AddConnectionWizard
* *
* Multi-step modal for adding a new connector with optional knowledge * Streamlined multi-step modal for adding a new connector.
* ingestion consent and per-connection preferences (§2.6). * Steps are connector-type-aware:
* * Base: Connector Consent Connect
* Steps: * Microsoft: Connector Consent Admin Consent (optional) Connect
* 0 Connector wählen * Infomaniak: Connector Consent PAT Input (done)
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal'; import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi'; import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './AddConnectionWizard.module.css'; import styles from './AddConnectionWizard.module.css';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup'; export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState { interface WizardState {
step: 0 | 1 | 2 | 3; currentStep: StepId;
connector: ConnectorType | null; connector: ConnectorType | null;
knowledgeEnabled: boolean; knowledgeEnabled: boolean;
prefs: KnowledgePreferences; infomaniakToken: string;
adminConsentDone: boolean;
} }
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = { const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google', google: 'Google',
msft: 'Microsoft 365', msft: 'Microsoft 365',
clickup: 'ClickUp', clickup: 'ClickUp',
infomaniak: 'Infomaniak',
}; };
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = { const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />, google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />, msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />, clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
}; };
// --------------------------------------------------------------------------- function _getSteps(connector: ConnectorType | null): StepId[] {
// Cost estimate helper if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
// --------------------------------------------------------------------------- if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -157,11 +59,9 @@ function computeCostEstimate(
interface AddConnectionWizardProps { interface AddConnectionWizardProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onConnect: ( onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
type: ConnectorType, onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
knowledgeEnabled: boolean, onMsftAdminConsent?: () => void;
prefs: KnowledgePreferences | null,
) => Promise<void>;
isConnecting?: boolean; isConnecting?: boolean;
} }
@ -173,84 +73,93 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open, open,
onClose, onClose,
onConnect, onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false, isConnecting = false,
}) => { }) => {
const { t } = useLanguage();
const [state, setState] = useState<WizardState>({ const [state, setState] = useState<WizardState>({
step: 0, currentStep: 'connector',
connector: null, connector: null,
knowledgeEnabled: false, knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS }, infomaniakToken: '',
adminConsentDone: false,
}); });
const reset = () => const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } }); setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
const handleClose = () => { const handleClose = () => { reset(); onClose(); };
reset();
onClose(); const steps = _getSteps(state.connector);
const stepIndex = steps.indexOf(state.currentStep);
const goNext = () => {
const nextIdx = stepIndex + 1;
if (nextIdx < steps.length) {
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
}
}; };
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step })); const goBack = () => {
const setConnector = (connector: ConnectorType) => const prevIdx = stepIndex - 1;
setState(s => ({ ...s, connector, step: 1 })); if (prevIdx >= 0) {
const setKnowledgeEnabled = (v: boolean) => setState(s => ({ ...s, currentStep: steps[prevIdx] }));
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 })); }
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) => };
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const handleConnect = async () => { const selectConnector = (c: ConnectorType) => {
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
};
const setConsent = (enabled: boolean) => {
setState(s => ({ ...s, knowledgeEnabled: enabled }));
goNext();
};
const handleFinalConnect = async () => {
if (!state.connector) return; if (!state.connector) return;
await onConnect( if (state.connector === 'infomaniak' && onInfomaniakConnect) {
state.connector, await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
state.knowledgeEnabled, } else {
state.knowledgeEnabled ? state.prefs : null, await onConnect(state.connector, state.knowledgeEnabled);
); }
reset(); reset();
onClose(); onClose();
}; };
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return ( return (
<Modal <Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */} {/* Stepper */}
<div className={styles.stepper}> <div className={styles.stepper}>
{[0, 1, 2, 3].map(i => ( {steps.map((s, i) => (
<div <div
key={i} key={s}
className={[ className={[
styles.stepDot, styles.stepDot,
state.step === i ? styles.stepDotActive : '', stepIndex === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '', stepIndex > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')} ].join(' ')}
> >
{state.step > i ? <FaCheck size={10} /> : i + 1} {stepIndex > i ? <FaCheck size={10} /> : i + 1}
</div> </div>
))} ))}
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{/* ---- Step 0: Connector ---- */} {/* ---- Step: Connector ---- */}
{state.step === 0 && ( {state.currentStep === 'connector' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3> <h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p> <p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
<div className={styles.connectorGrid}> <div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( {(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button <button
key={type} key={type}
type="button" type="button"
className={styles.connectorCard} className={styles.connectorCard}
onClick={() => setConnector(type)} onClick={() => selectConnector(type)}
> >
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span> <span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span> <span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -260,253 +169,119 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div> </div>
)} )}
{/* ---- Step 1: Consent ---- */} {/* ---- Step: Consent ---- */}
{state.step === 1 && ( {state.currentStep === 'consent' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div> <h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche {t('Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus {provider} zurückgreifen kann?', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : t('diesem Dienst') })}
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
</p> </p>
<p className={styles.stepHint}> <p className={styles.stepHint}>
Du kannst diese Einstellung später in den Verbindungsdetails ändern. {t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
</p>
<div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> {t('Ja, aktivieren')}
</button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
{t('Nein, überspringen')}
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div>
</div>
)}
{/* ---- Step: MSFT Admin Consent ---- */}
{state.currentStep === 'msftAdminConsent' && (
<div className={styles.stepContent}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div>
<h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
<p className={styles.stepBody}>
{t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
</p>
<p className={styles.stepHint}>
{t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
</p> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button <button
type="button" type="button"
className={styles.consentButtonYes} className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)} onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
> >
<FaCheck /> Ja, aufnehmen <FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
</button> </button>
<button <button type="button" className={styles.consentButtonNo} onClick={goNext}>
type="button" {t('Überspringen')}
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
Zurück
</button>
</div> </div>
</div> </div>
)} )}
{/* ---- Step 2: Preferences ---- */} {/* ---- Step: Infomaniak PAT ---- */}
{state.step === 2 && ( {state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3> <h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
<p className={styles.stepHint}> <p className={styles.stepBody}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden. {t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
</p> </p>
<input
<div className={styles.prefGroup}> type="password"
<label className={styles.prefLabel}> placeholder="pat_..."
<FaShieldAlt className={styles.prefIcon} /> value={state.infomaniakToken}
Anonymisierung vor dem Indexieren onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
<input className={styles.patInput}
type="checkbox" autoFocus
checked={!!state.prefs.neutralizeBeforeEmbed} />
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
Zurück <button
</button> type="button"
<button type="button" className={styles.navNext} onClick={() => setStep(3)}> className={styles.navConnect}
Weiter <FaArrowRight size={12} /> onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? t('Verbinden…') : t('Verbinden')}
{!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* ---- Step 3: Summary ---- */} {/* ---- Step: Connect ---- */}
{state.step === 3 && ( {state.currentStep === 'connect' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3> <h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
<div className={styles.summary}> <div className={styles.summary}>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span> <span className={styles.summaryKey}>{t('Anbieter')}</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp; {state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'} {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span> </span>
</div> </div>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Wissensdatenbank</span> <span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{state.knowledgeEnabled ? 'Aktiv' : 'Nicht aktiv'} {state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
</span> </span>
</div> </div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div> </div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleConnect} onClick={handleFinalConnect}
disabled={isConnecting} disabled={isConnecting}
> >
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`} {isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })}
{!isConnecting && <FaArrowRight size={12} />} {!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* ChatInput -- Shared chat input component. * ChatInput -- Shared chat input component.
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* ChatMessageList -- Shared chat message display component. * ChatMessageList -- Shared chat message display component.
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ChatMessageList } from './ChatMessageList'; export { ChatMessageList } from './ChatMessageList';
export type { ChatMessage } from './ChatMessageList'; export type { ChatMessage } from './ChatMessageList';
export { ChatInput } from './ChatInput'; export { ChatInput } from './ChatInput';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { IoIosDownload, IoIosCopy } from 'react-icons/io'; import { IoIosDownload, IoIosCopy } from 'react-icons/io';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { IoIosDownload } from 'react-icons/io'; import { IoIosDownload } from 'react-icons/io';
import { Popup, PopupAction } from '../UiComponents/Popup/Popup'; import { Popup, PopupAction } from '../UiComponents/Popup/Popup';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ContentPreview } from './ContentPreview'; export { ContentPreview } from './ContentPreview';
export type { ContentPreviewProps } from './ContentPreview'; export type { ContentPreviewProps } from './ContentPreview';
export { UrlContentPreview } from './UrlContentPreview'; export { UrlContentPreview } from './UrlContentPreview';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
interface ApplicationRendererProps { interface ApplicationRendererProps {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
interface HtmlRendererProps { interface HtmlRendererProps {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
interface ImageRendererProps { interface ImageRendererProps {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState } from 'react'; import { useState } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
// @ts-ignore // @ts-ignore
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { IoIosWarning } from 'react-icons/io'; import { IoIosWarning } from 'react-icons/io';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { renderAsync } from 'docx-preview'; import { renderAsync } from 'docx-preview';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { JsonRenderer } from './JsonRenderer'; export { JsonRenderer } from './JsonRenderer';
export { ImageRenderer } from './ImageRenderer'; export { ImageRenderer } from './ImageRenderer';
export { TextRenderer } from './TextRenderer'; export { TextRenderer } from './TextRenderer';

View file

@ -1,14 +1,16 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField. * Workflow Flow Editor - Data flow context for Data Picker and DynamicValueField.
* Extended with portTypeCatalog and systemVariables for the Typed Port System. * 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 { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowAutomationApi';
export interface Automation2DataFlowContextValue { export interface WorkflowDataFlowContextValue {
currentNodeId: string; currentNodeId: string;
nodes: CanvasNode[]; nodes: CanvasNode[];
connections: CanvasConnection[]; connections: CanvasConnection[];
@ -19,6 +21,8 @@ export interface Automation2DataFlowContextValue {
systemVariables: Record<string, SystemVariable>; systemVariables: Record<string, SystemVariable>;
/** Canonical form field types from the API — maps UI type id to portType primitive. */ /** Canonical form field types from the API — maps UI type id to portType primitive. */
formFieldTypes: FormFieldType[]; formFieldTypes: FormFieldType[];
/** Backend-driven condition operators per valueKind (flow.ifElse). */
conditionOperatorCatalog: Record<string, ConditionOperatorDef[]>;
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[];
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */ /** Present when rendered inside the flow editor (ConnectionPicker / tools). */
@ -28,13 +32,13 @@ export interface Automation2DataFlowContextValue {
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null; parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
} }
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null); const WorkflowDataFlowContext = createContext<WorkflowDataFlowContextValue | null>(null);
export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null { export function useWorkflowDataFlow(): WorkflowDataFlowContextValue | null {
return useContext(Automation2DataFlowContext); return useContext(WorkflowDataFlowContext);
} }
interface Automation2DataFlowProviderProps { interface WorkflowDataFlowProviderProps {
node: CanvasNode | null; node: CanvasNode | null;
nodes: CanvasNode[]; nodes: CanvasNode[];
connections: CanvasConnection[]; connections: CanvasConnection[];
@ -44,12 +48,13 @@ interface Automation2DataFlowProviderProps {
portTypeCatalog?: Record<string, PortSchema>; portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>; systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[]; formFieldTypes?: FormFieldType[];
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
instanceId?: string; instanceId?: string;
request?: ApiRequestFunction; request?: ApiRequestFunction;
children: React.ReactNode; children: React.ReactNode;
} }
export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({ export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> = ({
node, node,
nodes, nodes,
connections, connections,
@ -59,11 +64,12 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog = {}, portTypeCatalog = {},
systemVariables = {}, systemVariables = {},
formFieldTypes = [], formFieldTypes = [],
conditionOperatorCatalog = {},
instanceId, instanceId,
request, request,
children, children,
}) => { }) => {
const value = useMemo((): Automation2DataFlowContextValue | null => { const value = useMemo((): WorkflowDataFlowContextValue | null => {
if (!node) return null; if (!node) return null;
const formTypeToPort: Record<string, string> = Object.fromEntries( const formTypeToPort: Record<string, string> = Object.fromEntries(
formFieldTypes.map((f) => [f.id, f.portType]) formFieldTypes.map((f) => [f.id, f.portType])
@ -120,6 +126,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog, portTypeCatalog,
systemVariables, systemVariables,
formFieldTypes, formFieldTypes,
conditionOperatorCatalog,
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),
@ -127,11 +134,11 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
request, request,
parseGraphDefinedSchema, parseGraphDefinedSchema,
}; };
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, instanceId, request]); }, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
return ( return (
<Automation2DataFlowContext.Provider value={value}> <WorkflowDataFlowContext.Provider value={value}>
{children} {children}
</Automation2DataFlowContext.Provider> </WorkflowDataFlowContext.Provider>
); );
}; };

View file

@ -1,99 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.4 (T10): CanvasHeader Run-button gating logic.
// Verifies the AC-9 patch — Save always enabled (unless saving), Run blocked
// when executeBlockedReason is set + warning toast surfaced as amber banner.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/workflowApi';
vi.mock('../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
import { CanvasHeader } from './CanvasHeader';
const _workflows: Automation2Workflow[] = [];
function _renderHeader(overrides: Partial<React.ComponentProps<typeof CanvasHeader>> = {}) {
const props: React.ComponentProps<typeof CanvasHeader> = {
workflows: _workflows,
currentWorkflowId: null,
onWorkflowSelect: () => {},
onNew: () => {},
onSave: () => {},
onExecute: () => {},
saving: false,
executing: false,
hasNodes: true,
executeResult: null,
...overrides,
};
return render(<CanvasHeader {...props} />);
}
describe('CanvasHeader Run-button (T10)', () => {
it('runs `onExecute` when not blocked', async () => {
const onExecute = vi.fn();
_renderHeader({ onExecute });
await userEvent.click(screen.getByRole('button', { name: /Ausführen/i }));
expect(onExecute).toHaveBeenCalledTimes(1);
});
it('shows the "Pflicht-Felder fehlen" label and triggers `onExecuteBlockedClick` instead of `onExecute`', async () => {
const onExecute = vi.fn();
const onExecuteBlockedClick = vi.fn();
_renderHeader({
onExecute,
onExecuteBlockedClick,
executeBlockedReason: '2 Nodes mit Pflicht-Fehlern',
});
const btn = screen.getByRole('button', { name: /Pflicht-Felder fehlen/i });
expect(btn).toHaveAttribute('aria-disabled', 'true');
expect(btn).toHaveAttribute('title', '2 Nodes mit Pflicht-Fehlern');
await userEvent.click(btn);
expect(onExecute).not.toHaveBeenCalled();
expect(onExecuteBlockedClick).toHaveBeenCalledTimes(1);
});
it('disables the Run button while executing or when no nodes are present', () => {
const { rerender } = _renderHeader({ executing: true });
expect(screen.getByRole('button', { name: /Ausführen…/i })).toBeDisabled();
rerender(
<CanvasHeader
workflows={_workflows}
currentWorkflowId={null}
onWorkflowSelect={() => {}}
onNew={() => {}}
onSave={() => {}}
onExecute={() => {}}
saving={false}
executing={false}
hasNodes={false}
executeResult={null}
/>,
);
expect(screen.getByRole('button', { name: /Ausführen/i })).toBeDisabled();
});
});
describe('CanvasHeader executeResult banner (AC-9)', () => {
it('renders the warning text in amber when success+warning is present', () => {
const result: ExecuteGraphResponse = {
success: true,
warning: 'Gespeichert mit 3 Pflicht-Fehlern in 2 Nodes.',
};
_renderHeader({ executeResult: result });
expect(screen.getByText(/Gespeichert mit 3 Pflicht-Fehlern/i)).toBeInTheDocument();
});
it('renders the error text in red when success=false', () => {
const result: ExecuteGraphResponse = { success: false, error: 'Boom' };
_renderHeader({ executeResult: result });
expect(screen.getByText(/Boom/)).toBeInTheDocument();
});
});

View file

@ -1,35 +1,81 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result. * CanvasHeader - Workflow controls, version selector, and execute result.
*/ */
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa'; import {
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi'; FaPlay,
import styles from './Automation2FlowEditor.module.css'; FaSpinner,
FaCloudUploadAlt,
FaCloudDownloadAlt,
FaArchive,
FaBookmark,
FaCaretDown,
FaSave,
FaPlus,
FaChevronLeft,
FaChevronRight,
} from 'react-icons/fa';
import {
HiOutlineMagnifyingGlassMinus,
HiOutlineMagnifyingGlassPlus,
HiOutlineArrowUturnLeft,
HiOutlineArrowUturnRight,
HiOutlineTrash,
HiOutlineDocumentDuplicate,
HiOutlineChatBubbleLeftEllipsis,
HiOutlineSquares2X2,
} from 'react-icons/hi2';
import type { WorkflowDefinition, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowAutomationApi';
import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache'; import { getUserDataCache } from '../../../utils/userCache';
import { Button } from '../../UiComponents/Button';
interface TargetInstanceOption { const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
id: string;
label: string; export interface CanvasHeaderCanvasEditProps {
zoomPercent: number;
selectedNodeCount: number;
connectionSelected: boolean;
stickyNoteSelected: boolean;
connectionToolActive: boolean;
canUndo: boolean;
canRedo: boolean;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomPercentCommit: (percent: number) => void;
onFitWindow: () => void;
onResetView: () => void;
onUndo: () => void;
onRedo: () => void;
onDeleteSelection: () => void;
onDuplicateNode: () => void;
onToggleConnectionTool: () => void;
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
onAddCanvasComment: () => void;
/** Verschachtelte Rasterpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
onArrangeNodes: () => void;
} }
interface CanvasHeaderProps { interface CanvasHeaderProps {
workflows: Automation2Workflow[]; workflows: WorkflowDefinition[];
currentWorkflowId: string | null; currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void; onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void; onNew: () => void;
onSave: () => void; onSave: () => void;
onExecute: () => void; onExecute: () => void;
onWorkflowSettings?: () => void; onToggleWorkspacePanel?: () => void;
onToggleChat?: () => void; workspacePanelOpen?: boolean;
saving: boolean; saving: boolean;
executing: boolean; executing: boolean;
hasNodes: boolean; hasNodes: boolean;
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message /** When set, required-field graph errors block a normal run; message is the
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the * run button tooltip. Click still fires `onExecuteBlockedClick` to focus
* parent can navigate the user to the first offending node. */ * the first offending node. */
executeBlockedReason?: string | null; executeBlockedReason?: string | null;
onExecuteBlockedClick?: () => void; onExecuteBlockedClick?: () => void;
executeResult: ExecuteGraphResponse | null; executeResult: ExecuteGraphResponse | null;
@ -44,15 +90,11 @@ interface CanvasHeaderProps {
onSaveAsTemplate?: (scope: AutoTemplateScope) => void; onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean; templateSaving?: boolean;
onNewFromTemplate?: () => void; onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
onAutoLayout?: () => void;
/** Sysadmin-only: when true, NodeConfigPanel renders the static /** Sysadmin-only: when true, NodeConfigPanel renders the static
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */ * "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean; verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void; onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null; canvasEdit?: CanvasHeaderCanvasEditProps;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
} }
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> { function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -63,14 +105,18 @@ function _getStatusBadge(t: (key: string) => string): Record<string, { label: st
}; };
} }
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows, const _tb = 'secondary' as const;
const _ts = 'sm' as const;
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId, currentWorkflowId,
onWorkflowSelect, onWorkflowSelect,
onNew, onNew,
onSave, onSave,
onExecute, onExecute,
onWorkflowSettings, onToggleWorkspacePanel,
onToggleChat, workspacePanelOpen,
saving, saving,
executing, executing,
hasNodes, hasNodes,
@ -88,13 +134,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onSaveAsTemplate, onSaveAsTemplate,
templateSaving, templateSaving,
onNewFromTemplate, onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
verboseSchema, verboseSchema,
onVerboseSchemaChange, onVerboseSchemaChange,
targetFeatureInstanceId, canvasEdit,
onTargetInstanceChange,
targetInstanceOptions,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true; const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@ -109,38 +151,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
const [templateMenuOpen, setTemplateMenuOpen] = useState(false); const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null); const templateMenuRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(false); const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const [nameValue, setNameValue] = useState(''); const zoomMenuRef = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null); const [zoomInputDraft, setZoomInputDraft] = useState('');
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(() => { useEffect(() => {
if (editingName && nameInputRef.current) { const zp = canvasEdit?.zoomPercent;
nameInputRef.current.focus(); if (zp !== undefined) setZoomInputDraft(String(zp));
nameInputRef.current.select(); }, [canvasEdit?.zoomPercent]);
}
}, [editingName]);
useEffect(() => { useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => { const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false); if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false); if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
}; };
document.addEventListener('mousedown', _handleClickOutside); document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside); return () => document.removeEventListener('mousedown', _handleClickOutside);
@ -156,15 +180,106 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
[t] [t]
); );
const _titleHint = const _panelOpen = workspacePanelOpen ?? false;
onWorkflowRename && currentWorkflow const _runAriaLabel = executing
? `${currentWorkflow.label}${t('Klicken zum Umbenennen')}` ? t('Ausführen…')
: currentWorkflow?.label; : executeBlockedReason
? t('Pflicht-Felder fehlen')
: t('Ausführen');
const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
const _executeBannerSegmentClass = !executeResult
? ''
: executeResult.success
? executeResult.warning
? styles.canvasHeaderExecuteBannerWarning
: styles.canvasHeaderExecuteBannerSuccess
: executeResult.paused
? styles.canvasHeaderExecuteBannerPaused
: styles.canvasHeaderExecuteBannerError;
const _commitZoomDraft = () => {
if (!canvasEdit) return;
const raw = zoomInputDraft.replace(/%/g, '').replace(',', '.').trim();
const n = parseFloat(raw);
if (!Number.isFinite(n)) {
setZoomInputDraft(String(canvasEdit.zoomPercent));
return;
}
canvasEdit.onZoomPercentCommit(Math.min(400, Math.max(25, Math.round(n))));
setZoomMenuOpen(false);
};
const _canDeleteSelection =
!!canvasEdit &&
(canvasEdit.selectedNodeCount > 0 ||
canvasEdit.connectionSelected ||
canvasEdit.stickyNoteSelected);
const _singleNodeOnly =
!!canvasEdit && canvasEdit.selectedNodeCount === 1 && !canvasEdit.connectionSelected;
return ( return (
<div className={styles.canvasHeader}> <div className={styles.canvasHeader} data-suppress-flow-node-hotkeys="">
<div className={styles.canvasHeaderRow}> <div
<div className={styles.canvasHeaderContext}> className={styles.canvasHeaderToolbar}
role="toolbar"
aria-label={t('Workflow-Aktionen')}
>
{onToggleWorkspacePanel && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={_panelOpen ? FaChevronLeft : FaChevronRight}
className={styles.canvasHeaderIconBtn}
onClick={onToggleWorkspacePanel}
title={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
/>
)}
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaPlus}
className={`${styles.canvasHeaderIconBtn} ${onNewFromTemplate ? styles.canvasHeaderNewSplitMain : ''}`}
onClick={onNew}
title={t('Neuer leerer Workflow')}
aria-label={t('Neuer leerer Workflow')}
/>
{onNewFromTemplate && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaCaretDown}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Aus Vorlage…')}
aria-label={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
/>
)}
</div>
{newMenuOpen && onNewFromTemplate && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => {
onNewFromTemplate();
setNewMenuOpen(false);
}}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
</div>
)}
</div>
<select <select
className={styles.canvasHeaderWorkflowSelect} className={styles.canvasHeaderWorkflowSelect}
value={currentWorkflowId ?? ''} value={currentWorkflowId ?? ''}
@ -182,142 +297,53 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</option> </option>
))} ))}
</select> </select>
<div className={styles.canvasHeaderTitleBlock}> <Button
{currentWorkflowId && currentWorkflow ? (
editingName ? (
<input
ref={nameInputRef}
className={styles.canvasHeaderTitle}
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={_commitNameEdit}
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
/>
) : (
<h4
className={styles.canvasHeaderTitle}
style={{ cursor: onWorkflowRename ? 'pointer' : 'default' }}
onClick={_startNameEdit}
title={_titleHint}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={`${styles.canvasHeaderTitle} ${styles.canvasHeaderTitleMuted}`}>
{t('Neuer Workflow')}
</h4>
)}
</div>
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title={t('Workflowkonfiguration Einstieg/Starts')}
aria-label={t('Workflow-Konfiguration')}
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
<select
className={styles.canvasHeaderWorkflowSelect}
value={targetFeatureInstanceId ?? ''}
onChange={(e) => onTargetInstanceChange(e.target.value)}
aria-label={t('Ziel-Instanz')}
title={t('Ziel-Instanz für Daten-Scope')}
style={{ maxWidth: 200, fontSize: '0.8rem' }}
>
<option value="">{t('Ziel-Instanz wählen…')}</option>
{targetInstanceOptions.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)}
</div>
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMain}`}
onClick={onNew}
>
{t('Neu')}
</button>
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
</div>
{newMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNew(); setNewMenuOpen(false); }}
role="menuitem"
>
{t('Leerer Workflow')}
</button>
{onNewFromTemplate && (
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
)}
</div>
)}
</div>
<button
type="button" type="button"
className={styles.retryButton} variant={_tb}
onClick={onSave} size={_ts}
icon={saving ? undefined : FaSave}
className={styles.canvasHeaderIconBtn}
loading={saving}
disabled={saving} disabled={saving}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined} onClick={onSave}
> title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')} aria-label={t('Speichern')}
</button> />
<Button
{onAutoLayout && ( type="button"
<button variant={_tb}
type="button" size={_ts}
className={styles.retryButton} icon={executing ? undefined : FaPlay}
onClick={onAutoLayout} loading={executing}
disabled={!hasNodes} disabled={executing || !hasNodes}
title={t('Knoten automatisch anordnen')} className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
> onClick={() => {
<FaSitemap style={{ marginRight: '0.4rem' }} /> if (executeBlockedReason) {
{t('Anordnen')} onExecuteBlockedClick?.();
</button> return;
)} }
onExecute();
}}
aria-label={_runAriaLabel}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={_runTitle}
/>
{currentWorkflowId && onSaveAsTemplate && ( {currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}> <div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
onClick={() => setTemplateMenuOpen((p) => !p)} size={_ts}
icon={FaBookmark}
loading={templateSaving}
disabled={templateSaving} disabled={templateSaving}
onClick={() => setTemplateMenuOpen((p) => !p)}
title={t('Als Vorlage speichern')} title={t('Als Vorlage speichern')}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={templateMenuOpen} aria-expanded={templateMenuOpen}
> >
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>} {t('Als Vorlage')}
</button> </Button>
{templateMenuOpen && ( {templateMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu"> <div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => ( {(['user', 'instance', 'mandate'] as const).map((s) => (
@ -325,7 +351,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
key={s} key={s}
type="button" type="button"
className={styles.canvasHeaderMenuItem} className={styles.canvasHeaderMenuItem}
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }} onClick={() => {
onSaveAsTemplate(s);
setTemplateMenuOpen(false);
}}
role="menuitem" role="menuitem"
> >
{scopeLabels[s]} {scopeLabels[s]}
@ -336,53 +365,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</div> </div>
)} )}
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderRunButton}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
disabled={executing || !hasNodes}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={executeBlockedReason ?? undefined}
style={
executeBlockedReason
? {
background: 'rgba(220,53,69,0.10)',
borderColor: 'var(--danger-color, #dc3545)',
color: 'var(--danger-color, #dc3545)',
cursor: 'help',
}
: undefined
}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ flexShrink: 0 }} />
{t('Ausführen…')}
</>
) : executeBlockedReason ? (
<>
<FaPlay style={{ opacity: 0.5, flexShrink: 0 }} />
{t('Pflicht-Felder fehlen')}
</>
) : (
<>
<FaPlay style={{ flexShrink: 0 }} />
{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>
)}
{_isSysAdmin && onVerboseSchemaChange && ( {_isSysAdmin && onVerboseSchemaChange && (
<label <label
className={styles.canvasHeaderSysadmin} className={styles.canvasHeaderSysadmin}
@ -392,14 +374,173 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
type="checkbox" type="checkbox"
checked={!!verboseSchema} checked={!!verboseSchema}
onChange={(e) => onVerboseSchemaChange(e.target.checked)} onChange={(e) => onVerboseSchemaChange(e.target.checked)}
style={{ margin: 0 }} className={styles.canvasHeaderSysadminInput}
/> />
{t('Schema-Details')} {t('Schema-Details')}
</label> </label>
)} )}
</div>
</div> </div>
{canvasEdit && (
<div
className={styles.canvasHeaderEditRow}
role="toolbar"
aria-label={t('Canvas bearbeiten')}
>
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
<div className={styles.canvasHeaderZoomInputWrap}>
<input
type="text"
inputMode="numeric"
className={styles.canvasHeaderZoomInput}
value={zoomInputDraft}
onChange={(e) => setZoomInputDraft(e.target.value)}
onBlur={_commitZoomDraft}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitZoomDraft();
}
}}
aria-label={t('Zoomstufe (Prozent)')}
title={t('Zoomstufe (Prozent)')}
/>
<span className={styles.canvasHeaderZoomSuffix} aria-hidden>
%
</span>
</div>
<button
type="button"
className={styles.canvasHeaderZoomChevronBtn}
onClick={() => setZoomMenuOpen((p) => !p)}
aria-label={t('Zoom-Voreinstellungen')}
aria-haspopup="menu"
aria-expanded={zoomMenuOpen}
title={t('Zoom-Voreinstellungen')}
>
<FaCaretDown aria-hidden />
</button>
{zoomMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onFitWindow();
setZoomMenuOpen(false);
}}
>
{t('Ansicht an Fenster anpassen')}
</button>
<button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onResetView();
setZoomMenuOpen(false);
}}
>
{t('Ansicht zurücksetzen')}
</button>
{ZOOM_PRESET_PERCENTS.map((pct) => (
<button
key={pct}
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onZoomPercentCommit(pct);
setZoomMenuOpen(false);
}}
>
{pct}%
</button>
))}
</div>
)}
</div>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onZoomIn}
title={t('Vergrößern')}
aria-label={t('Vergrößern')}
>
<HiOutlineMagnifyingGlassPlus size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onZoomOut}
title={t('Verkleinern')}
aria-label={t('Verkleinern')}
>
<HiOutlineMagnifyingGlassMinus size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canUndo}
onClick={canvasEdit.onUndo}
title={t('Rückgängig')}
aria-label={t('Rückgängig')}
>
<HiOutlineArrowUturnLeft size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canRedo}
onClick={canvasEdit.onRedo}
title={t('Wiederholen')}
aria-label={t('Wiederholen')}
>
<HiOutlineArrowUturnRight size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_canDeleteSelection}
onClick={canvasEdit.onDeleteSelection}
title={t('Auswahl löschen')}
aria-label={t('Auswahl löschen')}
>
<HiOutlineTrash size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_singleNodeOnly}
onClick={canvasEdit.onDuplicateNode}
title={t('Knoten duplizieren')}
aria-label={t('Knoten duplizieren')}
>
<HiOutlineDocumentDuplicate size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!hasNodes}
onClick={canvasEdit.onArrangeNodes}
title={t('Knoten im Raster anordnen')}
aria-label={t('Knoten im Raster anordnen')}
>
<HiOutlineSquares2X2 size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onAddCanvasComment}
title={t('Kommentar auf dem Canvas einfügen')}
aria-label={t('Kommentar auf dem Canvas einfügen')}
>
<HiOutlineChatBubbleLeftEllipsis size={18} strokeWidth={2} aria-hidden />
</button>
</div>
)}
{currentWorkflowId && versions && versions.length > 0 && ( {currentWorkflowId && versions && versions.length > 0 && (
<div className={styles.canvasHeaderVersionRow}> <div className={styles.canvasHeaderVersionRow}>
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span> <span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
@ -418,108 +559,94 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
))} ))}
</select> </select>
<span <span
style={{ className={styles.canvasHeaderVersionBadge}
padding: '2px 8px', style={
borderRadius: 10, {
fontSize: '0.75rem', '--canvasHeaderBadgeBg': `${badge.color}22`,
fontWeight: 600, '--canvasHeaderBadgeFg': badge.color,
background: badge.color + '22', } as React.CSSProperties
color: badge.color, }
}}
> >
{badge.label} {badge.label}
</span> </span>
{currentVersion && currentStatus === 'draft' && onPublishVersion && ( {currentVersion && currentStatus === 'draft' && onPublishVersion && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaCloudUploadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onPublishVersion(currentVersion.id)} onClick={() => onPublishVersion(currentVersion.id)}
disabled={versionLoading} disabled={versionLoading}
title={t('Version veröffentlichen')} title={t('Version veröffentlichen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
<FaCloudUploadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichen')} {t('Veröffentlichen')}
</button> </Button>
)} )}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && ( {currentVersion && currentStatus === 'published' && onUnpublishVersion && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaCloudDownloadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onUnpublishVersion(currentVersion.id)} onClick={() => onUnpublishVersion(currentVersion.id)}
disabled={versionLoading} disabled={versionLoading}
title={t('Veröffentlichung zurücknehmen')} title={t('Veröffentlichung zurücknehmen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichung aufheben')} {t('Veröffentlichung aufheben')}
</button> </Button>
)} )}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && ( {currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaArchive}
className={styles.canvasHeaderVersionAction}
onClick={() => onArchiveVersion(currentVersion.id)} onClick={() => onArchiveVersion(currentVersion.id)}
disabled={versionLoading} disabled={versionLoading}
title={t('Version archivieren')} title={t('Version archivieren')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
<FaArchive style={{ marginRight: 4 }} /> {t('Archiv')}
Archiv </Button>
</button>
)} )}
{onCreateDraft && ( {onCreateDraft && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaPlus}
className={styles.canvasHeaderVersionAction}
onClick={onCreateDraft} onClick={onCreateDraft}
disabled={versionLoading} disabled={versionLoading}
title={t('Neuen Entwurf erstellen')} title={t('Neuen Entwurf erstellen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
+ Entwurf {t('+ Entwurf')}
</button> </Button>
)} )}
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />} {versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
</div> </div>
)} )}
{executeResult && ( {executeResult && (
<div <div
style={{ className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
marginTop: '0.5rem',
padding: '0.5rem',
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? executeResult.warning
? 'rgba(255,193,7,0.15)'
: '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
? executeResult.warning
? 'var(--warning-color,#ffc107)'
: 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
> >
{executeResult.success ? ( {executeResult.success ? (
executeResult.warning ? ( executeResult.warning ? (
<> {executeResult.warning}</> <>{executeResult.warning}</>
) : ( ) : (
<>{t('Ausführung abgeschlossen')}</> <>{t('Ausführung abgeschlossen')}</>
) )
) : (executeResult as { paused?: boolean }).paused ? ( ) : executeResult.paused ? (
<> <>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den {t('Workflow pausiert. Öffne ')}
Task zu bearbeiten. <strong>{t('Workflows/Tasks')}</strong>
{t(' in der Sidebar, um den Task zu bearbeiten.')}
</> </>
) : ( ) : (
<> {executeResult.error ?? t('Unbekannter Fehler')}</> <>{executeResult.error ?? t('Unbekannter Fehler')}</>
)} )}
</div> </div>
)} )}

View file

@ -1,7 +1,9 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* EditorChatPanel * EditorChatPanel
* *
* AI Chat sidebar for the GraphicalEditor. * AI Chat sidebar for the WorkflowAutomation editor.
* Streams responses via SSE (same pattern as Workspace chat). * Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput: * File & data-source attachment UX mirrors WorkspaceInput:
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB * - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
@ -87,7 +89,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
// Load persisted chat history from the backend whenever the workflow changes. // Load persisted chat history from the backend whenever the workflow changes.
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is // The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`. // returned by `GET /api/workflow-automation/{workflowId}/chat/messages`.
// For an unsaved workflow (workflowId == null) we just clear the panel. // For an unsaved workflow (workflowId == null) we just clear the panel.
useEffect(() => { useEffect(() => {
if (!workflowId) { if (!workflowId) {
@ -99,7 +101,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
setHistoryLoading(true); setHistoryLoading(true);
try { try {
const res = await api.get<PersistedEditorChatResponse>( const res = await api.get<PersistedEditorChatResponse>(
`/api/workflows/${instanceId}/${workflowId}/chat/messages`, `/api/workflow-automation/${workflowId}/chat/messages`,
); );
if (cancelled) return; if (cancelled) return;
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({ const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
@ -166,7 +168,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
const baseURL = api.defaults.baseURL || ''; const baseURL = api.defaults.baseURL || '';
const cleanup = startSseStream({ const cleanup = startSseStream({
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`, url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`,
body, body,
handlers: { handlers: {
onChunk: (event) => { onChunk: (event) => {
@ -227,7 +229,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
: m)); : m));
} }
try { try {
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`); await api.post(`/api/workflow-automation/${workflowId}/chat/stop`);
} catch { } catch {
} }
abortRef.current?.(); abortRef.current?.();

View file

@ -1,17 +1,19 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* EditorWorkflowChatList * EditorWorkflowChatList
* *
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated * UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow
* as one editor chat session. Lists workflows already loaded by the parent * is treated as one editor chat session. Lists workflows already loaded by the
* editor (no extra fetch), supports search and "+ Neu" to start a fresh * parent editor (no extra fetch), supports search and "+ Neu" to start a fresh
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses * workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
* GraphicalEditor data instead of the workspace endpoint. * WorkflowAutomation data instead of the workspace endpoint.
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import type { Automation2Workflow } from '../../../api/workflowApi'; import type { WorkflowDefinition } from '../../../api/workflowAutomationApi';
interface EditorWorkflowChatListProps { interface EditorWorkflowChatListProps {
workflows: Automation2Workflow[]; workflows: WorkflowDefinition[];
currentWorkflowId: string | null; currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => void; onSelect: (workflowId: string | null) => void;
onNew: () => void; onNew: () => void;

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* NodeConfigPanel - Generic parameter renderer for all node types. * NodeConfigPanel - Generic parameter renderer for all node types.
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType. * Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
@ -5,16 +7,93 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas'; import type { CanvasNode } from './FlowCanvas';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi'; import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowAutomationApi';
import type { ApiRequestFunction } from '../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../api/workflowAutomationApi';
import { getLabel } from '../nodes/shared/utils'; import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker'; import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation'; import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext'; import { useWorkflowDataFlow } from '../context/WorkflowDataFlowContext';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { AccordionList } from '../../UiComponents/AccordionList';
import type { AccordionListItem } from '../../UiComponents/AccordionList';
const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
const CONTEXT_EXTRACT_CHUNK_SET = new Set<string>(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
export function workflowParamUiValue(stored: Record<string, unknown>, param: NodeTypeParameter): unknown {
const raw = stored[param.name];
if (param.required) {
return raw !== undefined && raw !== null ? raw : param.default;
}
return raw;
}
function effectiveSchemaParamString(name: string, currentParams: Record<string, unknown>, nt: NodeType): string {
const raw = currentParams[name];
const s = raw !== undefined && raw !== null ? String(raw) : '';
if (s !== '') return s;
const meta = nt.parameters?.find((p) => p.name === name);
const d = meta?.default;
return d !== undefined && d !== null ? String(d) : '';
}
function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
return (
<span style={{ fontWeight: 700, fontSize: 12 }}>
{param.required ? (
<span style={{ color: 'var(--danger-color, #dc3545)', marginRight: 3 }} title={t('Pflichtfeld')}>
*
</span>
) : null}
{param.name}
</span>
);
}
function verboseSchemaTypeBadge(
verboseSchema: boolean,
param: NodeTypeParameter,
t: (key: string) => string,
): React.ReactElement | null {
if (!verboseSchema || !param.type) return null;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 6,
flexWrap: 'wrap',
minWidth: 0,
}}
>
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
</div>
);
}
interface NodeConfigPanelProps { interface NodeConfigPanelProps {
node: CanvasNode | null; node: CanvasNode | null;
@ -30,6 +109,35 @@ interface NodeConfigPanelProps {
verboseSchema?: boolean; verboseSchema?: boolean;
} }
/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
* (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
* parameter unless the referenced parameter's effective value matches.
*/
export function parameterVisibleForFrontendOptions(
param: NodeTypeParameter,
params: Record<string, unknown>,
nodeType: NodeType,
): boolean {
const fo = param.frontendOptions;
if (!fo || typeof fo !== 'object') return true;
const dependsOnRaw = fo.dependsOn as unknown;
const showWhenRaw = fo.showWhen as unknown;
if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
return true;
}
const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
const rawSibling = params[dependsOnRaw];
const siblingValue =
rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
const fallback =
depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
const effective = siblingValue !== '' ? siblingValue : fallback;
const allowed: string[] = Array.isArray(showWhenRaw)
? showWhenRaw.map((x) => String(x))
: [String(showWhenRaw)];
return allowed.includes(effective);
}
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node, export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
nodeType, nodeType,
language, language,
@ -62,7 +170,12 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
const updateParam = useCallback( const updateParam = useCallback(
(key: string, value: unknown) => { (key: string, value: unknown) => {
setParams((prev) => { setParams((prev) => {
const next = { ...prev, [key]: value }; const next = { ...prev };
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
const id = nodeIdRef.current; const id = nodeIdRef.current;
if (id) { if (id) {
if (notifyParentTimeoutRef.current != null) { if (notifyParentTimeoutRef.current != null) {
@ -79,7 +192,27 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[onParametersChange] [onParametersChange]
); );
const dataFlow = useAutomation2DataFlow(); const patchParams = useCallback(
(patch: Record<string, unknown>) => {
setParams((prev) => {
const next = { ...prev, ...patch };
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
clearTimeout(notifyParentTimeoutRef.current);
}
notifyParentTimeoutRef.current = setTimeout(() => {
notifyParentTimeoutRef.current = null;
onParametersChange(id, next);
}, 0);
}
return next;
});
},
[onParametersChange]
);
const dataFlow = useWorkflowDataFlow();
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {}; const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User // Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
@ -115,6 +248,149 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
.join('\n'); .join('\n');
}, [requiredErrors, nodeType, language]); }, [requiredErrors, nodeType, language]);
const extractContentAccordionItems = useMemo((): AccordionListItem<string>[] | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
const out: AccordionListItem<string>[] = [];
for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue;
if (param.name === 'context') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
const usePicker = _shouldUseRequiredPicker(param);
if (usePicker) {
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
),
});
continue;
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
if (param.name === 'outputMode') {
const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks';
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
{chunksNested ? (
<div style={{ marginTop: 8 }}>
<AccordionList<string>
key={`extract-chunks-${node.id}`}
defaultOpenId={null}
items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem<string> => {
const cp = byName.get(chunkName);
if (!cp) {
return { id: chunkName, title: chunkName, children: <></> };
}
const ft = cp.frontendType || 'text';
const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text;
return {
id: chunkName,
title: accordionExtractParamTitle(cp, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, cp, t)}
<ChunkRenderer
param={cp}
value={workflowParamUiValue(params, cp)}
onChange={(val: unknown) => updateParam(cp.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
};
})}
/>
</div>
) : null}
</div>
),
});
continue;
}
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
});
}
return out;
}, [
sortedParameters,
params,
nodeType,
language,
node?.id,
node?.type,
verboseSchema,
instanceId,
request,
patchParams,
updateParam,
t,
]);
const extractContentContextParam = useMemo((): NodeTypeParameter | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const param = sortedParameters.find((p) => p.name === 'context') ?? null;
if (!param) return null;
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
return param;
}, [node, nodeType, sortedParameters, params]);
if (!node || !nodeType) return null; if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.'); const isTrigger = node.type.startsWith('trigger.');
@ -219,78 +495,148 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong> <strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div> </div>
)} )}
{parameters.map((param: NodeTypeParameter) => { {extractContentAccordionItems !== null ? (
// Safety net: hidden params have no UI footprint at all — no row, <>
// no required-mark, no type-badge. Their value is system-set. {extractContentContextParam ? (
if (param.frontendType === 'hidden') return null; <div
const useRequiredPicker = _shouldUseRequiredPicker(param); key={`${node.id}-${extractContentContextParam.name}`}
if (useRequiredPicker) { style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{extractContentContextParam.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && extractContentContextParam.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{extractContentContextParam.type}
</span>
)}
</div>
<ContextBuilderRenderer
param={extractContentContextParam}
value={workflowParamUiValue(params, extractContentContextParam)}
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
) : null}
{extractContentAccordionItems.length > 0 ? (
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
) : null}
</>
) : (
parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return ( return (
<div key={param.name} style={{ marginBottom: 8 }}> <div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<RequiredAttributePicker <div
label={getLabel(param.description, language) || param.name} style={{
expectedType={param.type} display: 'flex',
value={params[param.name] ?? param.default} alignItems: 'center',
onChange={(val) => updateParam(param.name, val)} gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/> />
</div> </div>
); );
} })
const frontendType = param.frontendType || 'text'; )}
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={param.name} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
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> </div>
); );
}; };
@ -320,6 +666,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'featureInstance', 'featureInstance',
'sharepointFolder', 'sharepointFolder',
'sharepointFile', 'sharepointFile',
'userFileFolder',
'clickupList', 'clickupList',
'clickupTask', 'clickupTask',
'dataRef', 'dataRef',

View file

@ -1,14 +1,16 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* NodeListItem - Draggable node type item for the sidebar. * NodeListItem - Draggable node type item for the sidebar.
* Used in both regular categories and I/O sub-groups. * Used in both regular categories and I/O sub-groups.
*/ */
import React from 'react'; import React from 'react';
import type { NodeType } from '../../../api/workflowApi'; import type { NodeType } from '../../../api/workflowAutomationApi';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } from '../nodes/shared/utils'; import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils'; import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { AiBadge } from '../nodes/shared/AiBadge'; import { AiBadge } from '../nodes/shared/AiBadge';
interface NodeListItemProps { interface NodeListItemProps {

View file

@ -1,15 +1,17 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* NodeSidebar - Sidebar with searchable, collapsible node list. * NodeSidebar - Sidebar with searchable, collapsible node list.
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint). * Groups node types by category (start, input, flow, data, ai, email, sharepoint).
*/ */
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/workflowApi'; import type { NodeType, NodeTypeCategory } from '../../../api/workflowAutomationApi';
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 './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -21,7 +23,7 @@ interface NodeSidebarProps {
language: string; language: string;
expandedCategories: Set<string>; expandedCategories: Set<string>;
onToggleCategory: (id: string) => void; onToggleCategory: (id: string) => void;
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */ /** Hide palette categories (optional; e.g. feature flags) */
excludedCategories?: Set<string>; excludedCategories?: Set<string>;
style?: React.CSSProperties; style?: React.CSSProperties;
} }

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* RunTracingPanel * RunTracingPanel
* *
@ -7,7 +9,7 @@
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi'; import type { AutoStepLog } from '../../../api/workflowAutomationApi';
import api from '../../../api'; import api from '../../../api';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -98,7 +100,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
setLoading(true); setLoading(true);
try { try {
const data = await request({ const data = await request({
url: `/api/workflows/${instanceId}/runs/${runId}/steps`, url: `/api/workflow-automation/runs/${runId}/steps`,
method: 'get', method: 'get',
}); });
setSteps(data?.steps || []); setSteps(data?.steps || []);
@ -115,7 +117,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
loadSteps(); loadSteps();
const baseUrl = api.defaults.baseURL || ''; const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`; const url = `${baseUrl}/api/workflow-automation/runs/${runId}/stream`;
const es = new EventSource(url, { withCredentials: true }); const es = new EventSource(url, { withCredentials: true });
eventSourceRef.current = es; eventSourceRef.current = es;

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow. * TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
*/ */
@ -9,8 +11,8 @@ import {
type AutoWorkflowTemplate, type AutoWorkflowTemplate,
type AutoTemplateScope, type AutoTemplateScope,
type ApiRequestFunction, type ApiRequestFunction,
} from '../../../api/workflowApi'; } from '../../../api/workflowAutomationApi';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
interface TemplatePickerProps { interface TemplatePickerProps {
@ -50,7 +52,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
setLoading(true); setLoading(true);
try { try {
const scope = activeScope === 'all' ? undefined : activeScope; const scope = activeScope === 'all' ? undefined : activeScope;
const result = await fetchTemplates(request, instanceId, scope); const result = await fetchTemplates(request, scope);
setTemplates(Array.isArray(result) ? result : result.items); setTemplates(Array.isArray(result) ? result : result.items);
} catch { } catch {
setTemplates([]); setTemplates([]);

View file

@ -1,123 +0,0 @@
/**
* Workflow configuration primary start kind drives the canvas start node.
*/
import React, { useState, useEffect } from 'react';
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
import {
getPrimaryStartKind,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
return [
{ value: 'manual', label: t('Manueller Trigger') },
{ value: 'form', label: t('Formular') },
{ value: 'schedule', label: t('Zeitplan') },
{ value: 'always_on', label: t('Immer aktiv') },
];
}
interface WorkflowConfigurationModalProps {
open: boolean;
onClose: () => void;
invocations: WorkflowEntryPoint[];
onApply: (next: WorkflowEntryPoint[]) => void;
}
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
function normalizeLoadedKind(k: string): string {
if (_validKinds.includes(k)) return k;
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
if (k === 'api') return 'manual';
return 'manual';
}
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
onClose,
invocations,
onApply,
}) => {
const { t } = useLanguage();
const kindOptions = _getKindOptions(t);
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
const [titleDe, setTitleDe] = useState('');
useEffect(() => {
if (!open) return;
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
setKind(k);
const entry = invocations[0];
const entryTitle = entry?.title;
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
else setTitleDe('');
}, [open, invocations]);
if (!open) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const label =
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
onApply(next);
onClose();
};
return (
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
<div className={styles.workflowModal}>
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
{t('Workflow-Konfiguration')}
</h3>
<p className={styles.workflowModalHint}>
{t(
'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
)}
</p>
<form onSubmit={handleSubmit}>
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
{t('Titel der Start Node')}
</label>
<input
id="wf-start-title"
className={styles.workflowModalInput}
value={titleDe}
onChange={(e) => setTitleDe(e.target.value)}
placeholder={t('z.B. Angebot anlegen')}
/>
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
{kindOptions.map((o) => (
<label key={o.value} className={styles.workflowModalRadio}>
<input
type="radio"
name="kind"
value={o.value}
checked={kind === o.value}
onChange={() => setKind(o.value)}
/>
{o.label}
</label>
))}
</div>
<div className={styles.workflowModalActions}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
{t('Abbrechen')}
</button>
<button type="submit" className={styles.workflowModalBtnPrimary}>
{t('Übernehmen')}
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -1,5 +1,5 @@
/** /**
* Automation2 Flow Editor Styles * Workflow Flow Editor Styles
* Sidebar with node list + canvas area. * Sidebar with node list + canvas area.
*/ */
@ -246,6 +246,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
background: var(--canvas-bg, #fafafa); background: var(--canvas-bg, #fafafa);
} }
@ -254,47 +255,155 @@
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0); border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
overflow: visible;
} }
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */ .canvasHeaderToolbar {
.canvasHeaderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
}
@media (max-width: 900px) {
.canvasHeaderRow {
grid-template-columns: 1fr;
}
}
.canvasHeaderContext {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.4rem;
width: 100%;
padding: 0;
border-radius: 8px;
border: none;
background: none;
box-sizing: border-box;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderToolbar :global(button),
.canvasHeaderToolbar label {
margin-top: 0;
}
.canvasHeaderEditRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
width: 100%;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e8e8e8);
}
.canvasHeaderEditRow :global(button) {
margin-top: 0;
}
.canvasHeaderGhostIconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-primary, #333);
cursor: pointer;
box-sizing: border-box;
}
.canvasHeaderGhostIconBtn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
}
.canvasHeaderGhostIconBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.canvasHeaderZoomCombo {
position: relative;
display: inline-flex;
align-items: stretch;
flex: 0 0 auto;
}
.canvasHeaderZoomInputWrap {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
min-width: 4.25rem;
padding-left: 0.35rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px 0 0 6px;
border-right: none;
background: var(--bg-primary, #fff);
box-sizing: border-box;
min-height: 30px;
}
.canvasHeaderZoomInputWrap:focus-within {
border-color: var(--primary-color, #007bff);
}
.canvasHeaderZoomInput {
flex: 1 1 auto;
width: 2.25rem;
min-width: 0; min-width: 0;
flex: 1; padding: 0.28rem 0.15rem 0.28rem 0;
font-size: 0.8125rem;
border: none;
background: transparent;
color: var(--text-primary, #333);
text-align: right;
box-sizing: border-box;
min-height: 28px;
}
.canvasHeaderZoomInput:focus {
outline: none;
}
.canvasHeaderZoomSuffix {
flex-shrink: 0;
padding-right: 0.35rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #666);
user-select: none;
}
.canvasHeaderZoomChevronBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-height: 30px;
padding: 0;
border: 1px solid var(--border-color, #ccc);
border-radius: 0 6px 6px 0;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
cursor: pointer;
box-sizing: border-box;
}
.canvasHeaderZoomChevronBtn:hover {
background: rgba(0, 0, 0, 0.06);
} }
/* Closed <select> width must not follow the longest option label. */ /* Closed <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect { .canvasHeaderWorkflowSelect {
flex: 0 0 auto; flex: 0 1 12.5rem;
width: 12.5rem; min-width: 8rem;
max-width: 100%; max-width: 100%;
padding: 0.4rem 0.5rem; padding: 0.31rem 0.45rem;
min-height: 2rem; min-height: 30px;
font-size: 0.85rem; box-sizing: border-box;
font-size: 0.8125rem;
border: 1px solid var(--border-color, #ccc); border: 1px solid var(--border-color, #ccc);
border-radius: 6px; border-radius: var(--button-border-radius, 6px);
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
color: var(--text-primary, #333); color: var(--text-primary, #333);
} }
.canvasHeaderTitleBlock { .canvasHeaderTitleBlock {
flex: 1 1 8rem; flex: 1 1 auto;
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
@ -347,26 +456,27 @@
background: var(--bg-secondary, #f8f9fa); background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto; flex: 0 1 auto;
max-width: 100%; max-width: 100%;
margin-left: auto;
} }
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */ .canvasHeaderSplitPair :global(.button + .button) {
.canvasHeaderActionPanel button { margin-left: 0;
margin-top: 0;
} }
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */ .canvasHeaderRunBlocked {
.canvasHeaderRunButton { background: rgba(220, 53, 69, 0.1) !important;
min-width: 12.5rem; border: 1px solid var(--danger-color, #dc3545) !important;
display: inline-flex; color: var(--danger-color, #dc3545) !important;
align-items: center; cursor: help !important;
justify-content: center; box-shadow: none !important;
gap: 0.25rem;
} }
@media (max-width: 900px) { .canvasHeaderRunBlocked:hover:not(:disabled) {
.canvasHeaderActionPanel { filter: brightness(0.97);
justify-content: flex-start; }
}
.canvasHeaderRunBlocked :global(.buttonIcon) {
opacity: 0.5;
} }
.canvasHeaderVersionRow { .canvasHeaderVersionRow {
@ -380,7 +490,7 @@
width: 100%; width: 100%;
} }
.canvasHeaderVersionRow button { .canvasHeaderVersionRow :global(.button) {
margin-top: 0; margin-top: 0;
} }
@ -391,6 +501,57 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.canvasHeaderVersionBadge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
background: var(--canvasHeaderBadgeBg, transparent);
color: var(--canvasHeaderBadgeFg, inherit);
flex: 0 0 auto;
}
.canvasHeaderVersionAction {
font-size: 0.8rem !important;
padding: 0.25rem 0.6rem !important;
min-height: auto !important;
}
.canvasHeaderVersionSpinner {
font-size: 0.85rem;
}
.canvasHeaderExecuteBanner {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 6px;
font-size: 0.875rem;
}
.canvasHeaderExecuteBannerSuccess {
background: rgba(40, 167, 69, 0.15);
color: var(--success-color, #28a745);
}
.canvasHeaderExecuteBannerWarning {
background: rgba(255, 193, 7, 0.15);
color: var(--warning-color, #ffc107);
}
.canvasHeaderExecuteBannerPaused {
background: rgba(0, 123, 255, 0.15);
color: var(--primary-color, #007bff);
}
.canvasHeaderExecuteBannerError {
background: rgba(220, 53, 69, 0.15);
color: var(--danger-color, #dc3545);
}
.canvasHeaderSysadminInput {
margin: 0;
}
.canvasHeaderVersionSelect { .canvasHeaderVersionSelect {
width: 11rem; width: 11rem;
max-width: 100%; max-width: 100%;
@ -484,22 +645,183 @@
.canvasArea { .canvasArea {
flex: 1; flex: 1;
padding: 2rem; padding: 0;
min-height: 400px; min-height: 0;
overflow: hidden; overflow-x: visible;
overflow-y: hidden;
} }
.canvasDropZone { .canvasDropZone {
position: relative; position: relative;
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
overflow: hidden; /* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
overflow: visible;
border-radius: 8px; border-radius: 8px;
/* Infinite grid: on viewport, moves with pan/zoom via inline style */ /* Infinite grid: on viewport, moves with pan/zoom via inline style */
background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px); background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
background-repeat: repeat; background-repeat: repeat;
} }
.canvasDropZoneConnectionTool {
cursor: crosshair;
}
.canvasStickyNote {
position: relative;
pointer-events: auto;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteResize {
position: absolute;
right: 1px;
bottom: 1px;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
border: none;
border-radius: 2px 0 6px 0;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.12) 45%,
rgba(0, 0, 0, 0.12) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.18) 58%,
rgba(0, 0, 0, 0.18) 64%,
transparent 64%
);
box-sizing: border-box;
}
.canvasStickyNoteResize:hover {
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.2) 45%,
rgba(0, 0, 0, 0.2) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.26) 58%,
rgba(0, 0, 0, 0.26) 64%,
transparent 64%
);
}
.canvasStickyNoteResize:focus-visible {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteSelected {
box-shadow:
0 0 0 2px var(--primary-color, #007bff),
0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteToolbar {
display: flex;
align-items: center;
gap: 0.35rem;
min-height: 1.5rem;
padding: 0.15rem 0.25rem 0.2rem;
background: rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
cursor: grab;
user-select: none;
}
.canvasStickyNoteToolbar:active {
cursor: grabbing;
}
.canvasStickyNoteGrip {
flex: 1;
font-size: 0.7rem;
letter-spacing: -0.12em;
color: var(--text-muted, #666);
opacity: 0.85;
padding: 0 0.15rem;
}
.canvasStickyNoteSwatches {
display: flex;
flex-wrap: wrap;
gap: 3px;
justify-content: flex-end;
}
.canvasStickyNoteSwatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.22);
padding: 0;
cursor: pointer;
flex-shrink: 0;
box-sizing: border-box;
}
.canvasStickyNoteSwatch:hover {
filter: brightness(0.96);
}
.canvasStickyNoteSwatchActive {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteBody {
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--text-primary, #333);
border: 1px solid transparent;
border-radius: 0;
white-space: pre-wrap;
word-break: break-word;
cursor: text;
outline: none;
}
.canvasStickyNoteBody:focus-visible {
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.35);
}
.canvasStickyNoteTextarea {
display: block;
width: 100%;
margin: 0;
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
font-family: inherit;
color: var(--text-primary, #333);
border-style: solid;
border-width: 1px;
border-radius: 0;
box-shadow: none;
resize: none;
box-sizing: border-box;
outline: none;
}
.canvasStickyNoteTextarea:focus {
border-color: var(--primary-color, #007bff);
box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.25);
}
.canvasContent { .canvasContent {
position: absolute; position: absolute;
left: 0; left: 0;
@ -695,6 +1017,8 @@
.handleWrapper:has(.handleOutput) { .handleWrapper:has(.handleOutput) {
flex-direction: row; flex-direction: row;
/* Bottom handles: keep circle math aligned with wires even when a label grows row height. */
align-items: flex-end;
} }
.handleWrapper:has(.handleInput) { .handleWrapper:has(.handleInput) {
@ -726,6 +1050,16 @@
cursor: copy; cursor: copy;
} }
/* Shell: stretches to full canvas-area height so inner `.nodeConfigPanel` can scroll. */
.nodeConfigPanelWrap {
flex-shrink: 0;
align-self: stretch;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* Node Config Panel /* Node Config Panel
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden` * Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
* pair acts as a safety net so long unbreakable strings (type names like * pair acts as a safety net so long unbreakable strings (type names like
@ -735,17 +1069,20 @@
* a long label rather than escaping to the right. * a long label rather than escaping to the right.
*/ */
.nodeConfigPanel { .nodeConfigPanel {
flex: 1;
min-height: 0;
padding: 1rem; padding: 1rem;
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
border-left: 1px solid var(--border-color, #e0e0e0); border-left: 1px solid var(--border-color, #e0e0e0);
width: 280px; width: 280px;
flex-shrink: 0;
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
position: relative;
z-index: 10;
} }
.nodeConfigPanel h4 { .nodeConfigPanel h4 {
@ -808,7 +1145,9 @@
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips /* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
(DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */ (DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */
.nodeConfigPanel .nodeConfigPanel
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) { button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn):not(
[data-accordion-header]
):not([data-schedule-day]) {
margin-top: 0.5rem; margin-top: 0.5rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
@ -901,6 +1240,12 @@
background: rgba(220, 53, 69, 0.1); background: rgba(220, 53, 69, 0.1);
} }
.formFieldOptionsBlock {
margin-top: 0.4rem;
padding-top: 0.45rem;
border-top: 1px solid var(--border-color, #e8e8e8);
}
/* Upload node config */ /* Upload node config */
.uploadNodeConfig { .uploadNodeConfig {
display: flex; display: flex;
@ -1491,24 +1836,6 @@
cursor: pointer; cursor: pointer;
} }
.canvasGearBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background: var(--bg-primary, #fff);
cursor: pointer;
font-size: 1rem;
}
.canvasGearBtn:hover {
background: var(--bg-hover, #f0f0f0);
}
.startsInput, .startsInput,
.startsSelect { .startsSelect {
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
@ -1771,6 +2098,39 @@
border-color: var(--primary-color, #007bff); border-color: var(--primary-color, #007bff);
} }
/* Curated picker: disclose technical / rare paths behind a single quiet control. */
.dataPickerCuratedToggle {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.38rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-secondary, #5c6370);
background: var(--bg-primary, #fff);
border: 1px dashed var(--border-color, #cfd4dc);
border-radius: 5px;
cursor: pointer;
text-align: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.dataPickerCuratedToggle:hover {
color: var(--text-primary, #333);
background: var(--bg-secondary, #f4f6f8);
border-color: var(--border-color, #b8c0cc);
}
.dataPickerCuratedDivider {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary, #8a9199);
margin: 0.75rem 0 0.35rem 0;
padding-left: 0.15rem;
}
/* Dynamic Value Field */ /* Dynamic Value Field */
.dynamicValueField { .dynamicValueField {
display: flex; display: flex;

View file

@ -1,8 +1,10 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Automation2FlowEditor * WorkflowFlowEditor
* *
* n8n-style flow builder with backend-driven node list. * n8n-style flow builder with backend-driven node list and categories.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync. * Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
*/ */
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@ -23,31 +25,34 @@ import {
createTemplateFromWorkflow, createTemplateFromWorkflow,
copyTemplate, copyTemplate,
importWorkflowFromFile, importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
type NodeType, type NodeType,
type NodeTypeCategory, type NodeTypeCategory,
type Automation2Graph, type WorkflowGraph,
type Automation2Workflow, type WorkflowDefinition,
type ExecuteGraphResponse, type ExecuteGraphResponse,
type WorkflowEntryPoint, type WorkflowEntryPoint,
type AutoVersion, type AutoVersion,
type AutoTemplateScope, type AutoTemplateScope,
} from '../../../api/workflowApi'; } from '../../../api/workflowAutomationApi';
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import {
FlowCanvas,
type CanvasNode,
type CanvasConnection,
type CanvasStickyNote,
type FlowCanvasHandle,
type FlowCanvasViewportEditState,
} from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar'; import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader'; import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker'; import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils'; import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils'; import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry'; import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation'; import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils'; import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; import { WorkflowDataFlowProvider } from '../context/WorkflowDataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt'; import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel'; import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
@ -55,18 +60,28 @@ import { EditorWorkflowChatList } from './EditorWorkflowChatList';
import { RunTracingPanel } from './RunTracingPanel'; import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] => const LOG = '[WorkflowEditor]';
buildInvocationsForPrimaryKind('manual', [], runLabel);
interface Automation2FlowEditorProps { const CANVAS_HISTORY_MAX = 50;
function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[]) {
return {
nodes: nodes.map((n) => ({
...n,
parameters: n.parameters ? { ...n.parameters } : {},
inputPorts: n.inputPorts?.map((p) => ({ ...p })),
outputPorts: n.outputPorts?.map((p) => ({ ...p })),
})),
connections: connections.map((c) => ({ ...c })),
};
}
interface WorkflowFlowEditorProps {
instanceId: string; instanceId: string;
mandateId?: string; mandateId?: string;
language?: string; language?: string;
@ -80,7 +95,7 @@ interface Automation2FlowEditorProps {
onSourcesChanged?: () => void; onSourcesChanged?: () => void;
} }
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId, export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instanceId,
mandateId, mandateId,
language = 'de', language = 'de',
initialWorkflowId, initialWorkflowId,
@ -92,32 +107,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSourcesChanged, onSourcesChanged,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt(); const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]); const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]); const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({}); const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({}); const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]); const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowAutomationApi').FormFieldType[]>([]);
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
Record<string, import('../../../api/workflowAutomationApi').ConditionOperatorDef[]>
>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>( const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee']) new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
); );
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]); const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]); const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const flowCanvasRef = useRef<FlowCanvasHandle>(null);
const canvasHistoryPastRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const canvasHistoryFutureRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const suppressCanvasHistoryRef = useRef(false);
const [canvasHistoryTick, setCanvasHistoryTick] = useState(0);
const [canvasViewportEdit, setCanvasViewportEdit] = useState<FlowCanvasViewportEditState>({
zoom: 1,
selectedNodeCount: 0,
connectionSelected: false,
stickyNoteSelected: false,
});
const [canvasConnectionToolActive, setCanvasConnectionToolActive] = useState(false);
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]);
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null); const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]); const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null); const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null); const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() => const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true); const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null); const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({}); const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
@ -129,20 +156,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
instanceId, instanceId,
mandateId: mandateId || '', mandateId: mandateId || '',
featureInstanceId: instanceId, featureInstanceId: instanceId,
surface: 'graphEditor', surface: 'workflowAutomation',
}), [instanceId, mandateId]); }), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]); const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null); const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false); const [versionLoading, setVersionLoading] = useState(false);
const didBootstrapEmptyCanvasRef = useRef(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId); const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => { const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; } try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
@ -196,7 +217,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
}, [leftPanelWidth, sidebarWidth]); }, [leftPanelWidth, sidebarWidth]);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []); const startNodeTypeIds = useMemo(
() => new Set(nodeTypes.filter((n) => n.category === 'start').map((n) => n.id)),
[nodeTypes]
);
const hasCanvasStartNode = useMemo(
() => canvasNodes.some((n) => startNodeTypeIds.has(n.type)),
[canvasNodes, startNodeTypeIds]
);
const missingStartNodeBlocking = useMemo(
() => canvasNodes.length > 0 && !hasCanvasStartNode,
[canvasNodes.length, hasCanvasStartNode]
);
const nodeOutputsPreview = useMemo( const nodeOutputsPreview = useMemo(
() => () =>
@ -219,26 +251,77 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]); const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]); const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const pushCanvasHistoryPastFromCurrent = useCallback(() => {
if (suppressCanvasHistoryRef.current) return;
const snap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const past = canvasHistoryPastRef.current;
const last = past[past.length - 1];
if (last && JSON.stringify(last) === JSON.stringify(snap)) return;
past.push(snap);
if (past.length > CANVAS_HISTORY_MAX) past.shift();
canvasHistoryFutureRef.current = [];
setCanvasHistoryTick((x) => x + 1);
}, [canvasNodes, canvasConnections]);
const onCanvasHistoryCheckpoint = useCallback(() => {
pushCanvasHistoryPastFromCurrent();
}, [pushCanvasHistoryPastFromCurrent]);
const undoCanvasEdit = useCallback(() => {
const past = canvasHistoryPastRef.current;
if (past.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = past.pop()!;
canvasHistoryFutureRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const redoCanvasEdit = useCallback(() => {
const fut = canvasHistoryFutureRef.current;
if (fut.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = fut.pop()!;
canvasHistoryPastRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const canCanvasUndo = useMemo(() => canvasHistoryPastRef.current.length > 0, [canvasHistoryTick]);
const canCanvasRedo = useMemo(() => canvasHistoryFutureRef.current.length > 0, [canvasHistoryTick]);
const applyGraphWithSync = useCallback( const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => { (
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen')); graph: WorkflowGraph | null | undefined,
setInvocations(inv); wfInvocations: WorkflowEntryPoint[] | undefined,
if (!graph?.nodes?.length) { opts?: { skipHistory?: boolean }
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language); ) => {
setCanvasNodes(synced.nodes); if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
setCanvasConnections(synced.connections); pushCanvasHistoryPastFromCurrent();
return;
} }
const { nodes, connections } = fromApiGraph(graph, nodeTypes); setInvocations(wfInvocations ?? []);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language); const g: WorkflowGraph = graph ?? { nodes: [], connections: [] };
setCanvasNodes(synced.nodes); const { nodes, connections } = fromApiGraph(g, nodeTypes);
setCanvasConnections(synced.connections); setCanvasNodes(nodes);
setCanvasConnections(connections);
}, },
[nodeTypes, language, t] [nodeTypes, pushCanvasHistoryPastFromCurrent]
); );
const handleFromApiGraph = useCallback( const handleFromApiGraph = useCallback(
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => { (graph: WorkflowGraph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations); applyGraphWithSync(graph, wfInvocations);
}, },
[applyGraphWithSync] [applyGraphWithSync]
@ -263,11 +346,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
}); });
return; return;
} }
if (missingStartNodeBlocking) {
setExecuteResult({
success: false,
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
});
return;
}
setExecuting(true); setExecuting(true);
setExecuteResult(null); setExecuteResult(null);
try { try {
const ep = currentWorkflowId ? invocations[0]?.id : undefined; const ep = currentWorkflowId ? invocations[0]?.id : undefined;
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, { const result = await executeGraph(request, graph, currentWorkflowId ?? undefined, {
...(ep ? { entryPointId: ep } : {}), ...(ep ? { entryPointId: ep } : {}),
}); });
setExecuteResult(result); setExecuteResult(result);
@ -280,7 +370,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setExecuting(false); setExecuting(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections); const graph = toApiGraph(canvasNodes, canvasConnections);
@ -296,19 +386,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
0, 0,
); );
const errorNodeCount = Object.keys(nodeErrors).length; const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => ({ const _buildSaveResult = (): ExecuteGraphResponse => {
success: true, const parts: string[] = [];
warning: if (errorCount > 0) {
errorCount > 0 parts.push(
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.') t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount)) .replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount)) .replace('{m}', String(errorNodeCount))
: undefined, );
}); }
if (canvasNodes.length > 0 && !hasCanvasStartNode) {
parts.push(t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'));
}
return {
success: true,
warning: parts.length ? parts.join(' ') : undefined,
};
};
setSaving(true); setSaving(true);
try { try {
if (currentWorkflowId) { if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId }); const updated = await updateWorkflow(request, currentWorkflowId, {
graph,
invocations,
targetFeatureInstanceId,
});
setInvocations(updated.invocations ?? []);
setExecuteResult(_buildSaveResult()); setExecuteResult(_buildSaveResult());
} else { } else {
const label = await promptInput(t('Workflow-Name:'), { const label = await promptInput(t('Workflow-Name:'), {
@ -320,14 +423,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setSaving(false); setSaving(false);
return; return;
} }
const created = await createWorkflow(request, instanceId, { const created = await createWorkflow(request, {
label: label.trim() || t('Neuer Workflow'), label: label.trim() || t('Neuer Workflow'),
graph, graph,
invocations, invocations,
targetFeatureInstanceId, targetFeatureInstanceId,
mandateId,
}); });
setCurrentWorkflowId(created.id); setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations); setInvocations(created.invocations ?? []);
setWorkflows((prev) => [...prev, created]); setWorkflows((prev) => [...prev, created]);
setExecuteResult(_buildSaveResult()); setExecuteResult(_buildSaveResult());
} }
@ -336,12 +440,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]); }, [request, mandateId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
const handleLoad = useCallback( const handleLoad = useCallback(
async (workflowId: string) => { async (workflowId: string) => {
try { try {
const wf = await fetchWorkflow(request, instanceId, workflowId); const wf = await fetchWorkflow(request, workflowId);
if (wf.graph) { if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations); handleFromApiGraph(wf.graph, wf.invocations);
} else { } else {
@ -361,9 +465,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId)); setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev)); setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
try { try {
const result = await fetchWorkflows(request, instanceId); const result = await fetchWorkflows(request);
setWorkflows(Array.isArray(result) ? result : result.items); setWorkflows(Array.isArray(result) ? result : result.items);
} catch (refreshErr) { } catch (refreshErr) {
console.error(`${LOG} workflows refresh failed`, refreshErr); console.error(`${LOG} workflows refresh failed`, refreshErr);
@ -376,7 +480,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
}); });
} }
}, },
[request, instanceId, handleFromApiGraph, applyGraphWithSync, t] [request, handleFromApiGraph, applyGraphWithSync, t]
); );
const handleWorkflowSelect = useCallback( const handleWorkflowSelect = useCallback(
@ -385,7 +489,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (workflowId) handleLoad(workflowId); if (workflowId) handleLoad(workflowId);
else { else {
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
} }
}, },
[handleLoad, applyGraphWithSync, t] [handleLoad, applyGraphWithSync, t]
@ -394,36 +498,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleNew = useCallback(() => { const handleNew = useCallback(() => {
setCurrentWorkflowId(null); setCurrentWorkflowId(null);
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
}, [applyGraphWithSync, t]); }, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => { const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) => setCanvasNodes((prev) => {
prev.map((n) => { const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n; if (n.id !== nodeId) return n;
const next = { ...n, parameters }; const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) { if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? []; const newCount = switchOutputCountFromCases(parameters.cases);
next.outputs = Math.max(1, cases.length); next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
} }
return next; return next;
}) });
); return nextNodes;
});
}, []); }, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => { const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) => setCanvasNodes((prev) => {
prev.map((n) => { const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n; if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch }; const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged }; const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) { if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? []; const newCount = switchOutputCountFromCases(merged.cases);
next.outputs = Math.max(1, cases.length); next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
} }
return next; return next;
}) });
); return nextNodes;
});
}, []); }, []);
const handleNodeUpdate = useCallback( const handleNodeUpdate = useCallback(
@ -435,24 +547,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[] []
); );
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 () => { const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await fetchNodeTypes(request, instanceId, language); const data = await fetchNodeTypes(request, mandateId || '', language);
setNodeTypes(data.nodeTypes); setNodeTypes(data.nodeTypes);
setCategories(data.categories); setCategories(data.categories);
if (data.portTypeCatalog) { if (data.portTypeCatalog) {
@ -461,6 +560,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} }
if (data.systemVariables) setSystemVariables(data.systemVariables); if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes); if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]); setNodeTypes([]);
@ -468,17 +568,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [instanceId, language, request]); }, [language, request]);
const loadWorkflows = useCallback(async () => { const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try { try {
const result = await fetchWorkflows(request, instanceId); const result = await fetchWorkflows(request, { mandateId: mandateId || undefined });
setWorkflows(Array.isArray(result) ? result : result.items); setWorkflows(Array.isArray(result) ? result : result.items);
} catch (e) { } catch (e) {
console.error(`${LOG} loadWorkflows failed`, e); console.error(`${LOG} loadWorkflows failed`, e);
} }
}, [instanceId, request]); }, [request, mandateId]);
useEffect(() => { useEffect(() => {
loadNodeTypes(); loadNodeTypes();
@ -488,6 +587,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
loadWorkflows(); loadWorkflows();
}, [loadWorkflows]); }, [loadWorkflows]);
useEffect(() => {
setCanvasStickyNotes([]);
}, [currentWorkflowId]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined); const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return; if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
@ -498,17 +601,34 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
useEffect(() => { useEffect(() => {
if (loading || nodeTypes.length === 0) return; if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return; if (currentWorkflowId || initialWorkflowId) {
if (canvasNodes.length > 0) return; didBootstrapEmptyCanvasRef.current = false;
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); return;
}
if (didBootstrapEmptyCanvasRef.current) return;
didBootstrapEmptyCanvasRef.current = true;
if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) {
return;
}
console.debug(`${LOG} bootstrapping empty canvas`, {
currentWorkflowId,
initialWorkflowId,
canvasNodes: canvasNodes.length,
canvasConnections: canvasConnections.length,
invocations: invocations.length,
});
applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true,
});
}, [ }, [
loading, loading,
nodeTypes.length, nodeTypes.length,
currentWorkflowId, currentWorkflowId,
initialWorkflowId, initialWorkflowId,
canvasNodes.length, canvasNodes.length,
canvasConnections.length,
invocations.length,
applyGraphWithSync, applyGraphWithSync,
t,
]); ]);
const toggleCategory = useCallback((id: string) => { const toggleCategory = useCallback((id: string) => {
@ -522,7 +642,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleDropNodeType = useCallback( const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => { (nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId); const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return; if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@ -548,17 +667,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
); );
const loadVersions = useCallback(async () => { const loadVersions = useCallback(async () => {
if (!instanceId || !currentWorkflowId) { if (!currentWorkflowId) {
setVersions([]); setVersions([]);
return; return;
} }
try { try {
const v = await fetchVersions(request, instanceId, currentWorkflowId); const v = await fetchVersions(request, currentWorkflowId);
setVersions(v); setVersions(v);
} catch (e) { } catch (e) {
console.error(`${LOG} loadVersions failed`, e); console.error(`${LOG} loadVersions failed`, e);
} }
}, [instanceId, currentWorkflowId, request]); }, [currentWorkflowId, request]);
useEffect(() => { useEffect(() => {
loadVersions(); loadVersions();
@ -579,10 +698,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handlePublishVersion = useCallback( const handlePublishVersion = useCallback(
async (versionId: string) => { async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
await publishVersion(request, instanceId, versionId); await publishVersion(request, versionId);
await loadVersions(); await loadVersions();
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -590,15 +708,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, instanceId, loadVersions] [request, loadVersions]
); );
const handleUnpublishVersion = useCallback( const handleUnpublishVersion = useCallback(
async (versionId: string) => { async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
await unpublishVersion(request, instanceId, versionId); await unpublishVersion(request, versionId);
await loadVersions(); await loadVersions();
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -606,15 +723,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, instanceId, loadVersions] [request, loadVersions]
); );
const handleArchiveVersion = useCallback( const handleArchiveVersion = useCallback(
async (versionId: string) => { async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
await archiveVersion(request, instanceId, versionId); await archiveVersion(request, versionId);
await loadVersions(); await loadVersions();
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -622,14 +738,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, instanceId, loadVersions] [request, loadVersions]
); );
const handleCreateDraft = useCallback(async () => { const handleCreateDraft = useCallback(async () => {
if (!instanceId || !currentWorkflowId) return; if (!currentWorkflowId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
const draft = await createDraftVersion(request, instanceId, currentWorkflowId); const draft = await createDraftVersion(request, currentWorkflowId);
await loadVersions(); await loadVersions();
setCurrentVersionId(draft.id); setCurrentVersionId(draft.id);
} catch (e: unknown) { } catch (e: unknown) {
@ -637,16 +753,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setVersionLoading(false); setVersionLoading(false);
} }
}, [request, instanceId, currentWorkflowId, loadVersions]); }, [request, currentWorkflowId, loadVersions]);
// Template: save current workflow as template // Template: save current workflow as template
const [templateSaving, setTemplateSaving] = useState(false); const [templateSaving, setTemplateSaving] = useState(false);
const handleSaveAsTemplate = useCallback( const handleSaveAsTemplate = useCallback(
async (scope: AutoTemplateScope) => { async (scope: AutoTemplateScope) => {
if (!instanceId || !currentWorkflowId) return; if (!currentWorkflowId) return;
setTemplateSaving(true); setTemplateSaving(true);
try { try {
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope); await createTemplateFromWorkflow(request, currentWorkflowId, scope);
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse); setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -654,16 +770,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setTemplateSaving(false); setTemplateSaving(false);
} }
}, },
[request, instanceId, currentWorkflowId] [request, currentWorkflowId]
); );
// Template: new workflow from template // Template: new workflow from template
const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const handleNewFromTemplate = useCallback( const handleNewFromTemplate = useCallback(
async (templateId: string) => { async (templateId: string) => {
if (!instanceId) return;
try { try {
const wf = await copyTemplate(request, instanceId, templateId); const wf = await copyTemplate(request, templateId);
setWorkflows((prev) => [...prev, wf]); setWorkflows((prev) => [...prev, wf]);
setCurrentWorkflowId(wf.id); setCurrentWorkflowId(wf.id);
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations); if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
@ -672,34 +787,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} }
}, },
[request, instanceId, handleFromApiGraph] [request, handleFromApiGraph]
); );
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
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) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`${LOG} rename failed`, e);
showError(t('Workflow umbenennen fehlgeschlagen: {msg}', { msg }));
}
}, [request, instanceId, showError, t]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
}, [canvasConnections]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
@ -741,7 +834,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language} language={language}
expandedCategories={expandedCategories} expandedCategories={expandedCategories}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle} style={_sidebarStyle}
/> />
); );
@ -749,15 +841,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected = const configurableSelected =
selectedNode && selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) => [
selectedNode.type.startsWith(p) 'input.',
); 'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
const canvasHeaderEdit = useMemo(
() => ({
zoomPercent: Math.round(canvasViewportEdit.zoom * 100),
selectedNodeCount: canvasViewportEdit.selectedNodeCount,
connectionSelected: canvasViewportEdit.connectionSelected,
stickyNoteSelected: canvasViewportEdit.stickyNoteSelected,
connectionToolActive: canvasConnectionToolActive,
canUndo: canCanvasUndo,
canRedo: canCanvasRedo,
onZoomIn: () => flowCanvasRef.current?.zoomIn(),
onZoomOut: () => flowCanvasRef.current?.zoomOut(),
onZoomPercentCommit: (pct: number) => flowCanvasRef.current?.setZoomPercent(pct),
onFitWindow: () => flowCanvasRef.current?.fitWindow(),
onResetView: () => flowCanvasRef.current?.resetView(),
onUndo: undoCanvasEdit,
onRedo: redoCanvasEdit,
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
}),
[
canvasViewportEdit,
canvasConnectionToolActive,
canCanvasUndo,
canCanvasRedo,
undoCanvasEdit,
redoCanvasEdit,
]
);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */} {/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<> {leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}> <div
data-suppress-flow-node-hotkeys=""
style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}
>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => ( {(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button <button
@ -807,12 +945,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
activeTab={udbTab as UdbTab} activeTab={udbTab as UdbTab}
onTabChange={(tab) => setUdbTab(tab as LeftTab)} onTabChange={(tab) => setUdbTab(tab as LeftTab)}
hideTabs={['chats']} hideTabs={['chats']}
onFileSelect={onFileSelect} onFileSelect={async (fileId, fileName) => {
onSourcesChanged={onSourcesChanged} if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) {
onWorkflowImportedFromFile={async (workflowId) => { try {
await loadWorkflows(); const result = await importWorkflowFromFile(request, { fileId });
handleWorkflowSelect(workflowId); await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
} catch (e) {
console.error('[workflowAutomation] workflow file import failed', e);
}
return;
}
onFileSelect?.(fileId, fileName);
}} }}
onSourcesChanged={onSourcesChanged}
/> />
)} )}
</div> </div>
@ -829,15 +975,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNew={handleNew} onNew={handleNew}
onSave={handleSave} onSave={handleSave}
onExecute={handleExecute} onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)} onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)} workspacePanelOpen={leftPanelOpen}
saving={saving} saving={saving}
executing={executing} executing={executing}
hasNodes={canvasNodes.length > 0} hasNodes={canvasNodes.length > 0}
executeBlockedReason={ executeBlockedReason={
hasGraphErrors hasGraphErrors
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.') ? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
: null : missingStartNodeBlocking
? t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.')
: null
} }
onExecuteBlockedClick={() => { onExecuteBlockedClick={() => {
if (firstErrorNodeId) { if (firstErrorNodeId) {
@ -857,17 +1005,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSaveAsTemplate={handleSaveAsTemplate} onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving} templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)} onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema} verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema} onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId} canvasEdit={canvasHeaderEdit}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/> />
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}> <div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<FlowCanvas <FlowCanvas
ref={flowCanvasRef}
nodes={canvasNodes} nodes={canvasNodes}
connections={canvasConnections} connections={canvasConnections}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
@ -879,13 +1024,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSelectionChange={setSelectedNode} onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined} highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors} nodeErrors={nodeErrors}
onViewportEditState={setCanvasViewportEdit}
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
onConnectionToolActiveChange={setCanvasConnectionToolActive}
stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes}
onExternalDrop={async (mime, payload) => { onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow' || !instanceId) return false; if (mime !== 'application/json+workflow') return false;
const p = payload as { files?: Array<{ id: string }> } | undefined; const p = payload as { files?: Array<{ id: string }> } | undefined;
const fileId = p?.files?.[0]?.id; const fileId = p?.files?.[0]?.id;
if (!fileId) return false; if (!fileId) return false;
try { try {
const result = await importWorkflowFromFile(request, instanceId, { fileId }); const result = await importWorkflowFromFile(request, { fileId });
await loadWorkflows(); await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id); if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true; return true;
@ -897,7 +1047,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
/> />
</div> </div>
{configurableSelected && selectedNode && ( {configurableSelected && selectedNode && (
<Automation2DataFlowProvider <div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
<WorkflowDataFlowProvider
node={selectedNode} node={selectedNode}
nodes={canvasNodes} nodes={canvasNodes}
connections={canvasConnections} connections={canvasConnections}
@ -907,6 +1058,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
portTypeCatalog={portTypeCatalog as Record<string, never>} portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>} systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes} formFieldTypes={formFieldTypes}
conditionOperatorCatalog={conditionOperatorCatalog}
instanceId={instanceId} instanceId={instanceId}
request={request} request={request}
> >
@ -921,14 +1073,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
request={request} request={request}
verboseSchema={verboseSchema} verboseSchema={verboseSchema}
/> />
</Automation2DataFlowProvider> </WorkflowDataFlowProvider>
</div>
)} )}
</div> </div>
</div> </div>
{/* Right panel: Nodes + Tracing tabs */} {/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} /> <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
data-suppress-flow-node-hotkeys=""
style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}
>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
<button <button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`} className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
@ -961,12 +1117,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
</div> </div>
<PromptDialog /> <PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker <TemplatePicker
open={templatePickerOpen} open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)} onClose={() => setTemplatePickerOpen(false)}
@ -978,4 +1128,4 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
); );
}; };
export default Automation2FlowEditor; export default WorkflowFlowEditor;

View file

@ -1,11 +1,14 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor'; // Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { WorkflowFlowEditor, WorkflowFlowEditor as FlowEditor } from './editor/WorkflowFlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel'; export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas'; export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas'; export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel'; export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar'; export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem'; export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader'; export { CanvasHeader } from './editor/CanvasHeader';
export type { CanvasHeaderCanvasEditProps } 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';

View file

@ -0,0 +1,106 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* One text field per option the text the end user sees in the dropdown.
* Stored as { value, label } with the same string so payload and UI stay in sync.
*/
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import type { FormFieldOptionRow } from './formFieldOptionsUtils';
export interface FormFieldOptionsEditorProps {
options: FormFieldOptionRow[];
onChange: (next: FormFieldOptionRow[]) => void;
className?: string;
rowClassName?: string;
}
export const FormFieldOptionsEditor: React.FC<FormFieldOptionsEditorProps> = ({
options,
onChange,
className,
rowClassName,
}) => {
const { t } = useLanguage();
const rootClass = className ?? '';
const lineClass = rowClassName ?? '';
const setOptionText = (idx: number, text: string) => {
const next = options.map((o, i) =>
i === idx ? { value: text, label: text } : o,
);
onChange(next);
};
return (
<div className={rootClass}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary, #666)', marginBottom: 4 }}>
{t('Auswahloptionen')}
</div>
{options.map((opt, idx) => (
<div
key={idx}
className={lineClass}
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
<input
type="text"
placeholder={t('z.B. On hold')}
value={opt.label || opt.value}
onChange={(e) => setOptionText(idx, e.target.value)}
style={{
flex: '1 1 120px',
minWidth: 80,
padding: '4px 6px',
fontSize: '0.8rem',
borderRadius: 4,
border: '1px solid var(--border-color, #ddd)',
boxSizing: 'border-box',
}}
/>
<button
type="button"
title={t('Option entfernen')}
onClick={() => onChange(options.filter((_, i) => i !== idx))}
style={{
padding: '4px 8px',
border: 'none',
background: 'transparent',
color: 'var(--text-tertiary, #999)',
cursor: 'pointer',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
}}
>
<FaTimes />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...options, { value: '', label: '' }])}
style={{
marginTop: 2,
padding: '4px 10px',
fontSize: '0.75rem',
borderRadius: 4,
border: '1px dashed var(--border-color, #bbb)',
background: 'var(--bg-primary, #fff)',
color: 'var(--text-secondary, #555)',
cursor: 'pointer',
}}
>
+ {t('Option')}
</button>
</div>
);
};

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Form node config - draggable fields, types, required toggle * Form node config - draggable fields, types, required toggle
*/ */
@ -6,14 +8,20 @@ import React from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa'; import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../shared/types'; import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/WorkflowFlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const ctx = useAutomation2DataFlow(); const ctx = useWorkflowDataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes ? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' })); : FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
@ -64,20 +72,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
</span> </span>
<div className={styles.formFieldInputs}> <div className={styles.formFieldInputs}>
<input <input
placeholder={t('name')} placeholder={t('Bezeichnung')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder={t('label')}
value={f.label ?? ''} value={f.label ?? ''}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const next = [...fields]; const next = [...fields];
next[i] = { ...next[i], label: e.target.value }; next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
updateParam('fields', next); updateParam('fields', next);
}} }}
/> />
@ -88,7 +88,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
value={f.type ?? 'text'} value={f.type ?? 'text'}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required }; const type = e.target.value as FormField['type'];
const row: FormField = { ...f, type };
if (formFieldTypeHasConfigurableOptions(type)) {
row.options = normalizeFormFieldOptions(row.options);
}
next[i] = row;
updateParam('fields', next); updateParam('fields', next);
}} }}
style={{ width: 'auto', minWidth: 90 }} style={{ width: 'auto', minWidth: 90 }}
@ -118,12 +123,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
<FaTimes /> <FaTimes />
</button> </button>
</div> </div>
{formFieldTypeHasConfigurableOptions(f.type) ? (
<FormFieldOptionsEditor
className={styles.formFieldOptionsBlock}
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => {
const next = [...fields];
next[i] = { ...next[i], options: opts };
updateParam('fields', next);
}}
/>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }]) updateParam('fields', [
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
type: 'text',
label: '',
required: false,
},
])
} }
> >
+ {t('Feld')} + {t('Feld')}

View file

@ -0,0 +1,42 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Helpers for optional select/multiselect rows on workflow form field definitions.
*/
export type FormFieldOptionRow = { value: string; label: string };
/** Field types where the author defines explicit { value, label } choices. */
export function formFieldTypeHasConfigurableOptions(typeId: string | undefined): boolean {
if (!typeId) return false;
return typeId === 'select' || typeId === 'enum';
}
export function normalizeFormFieldOptions(raw: unknown): FormFieldOptionRow[] {
if (!Array.isArray(raw)) return [];
return raw.map((o, i) => {
if (o && typeof o === 'object' && !Array.isArray(o)) {
const r = o as Record<string, unknown>;
const value = String(r.value ?? r.id ?? '');
const label = String(r.label ?? r.value ?? r.id ?? `Option ${i + 1}`);
return { value, label };
}
const s = String(o ?? '');
return { value: s, label: s };
});
}
/**
* Stable key for `payload.*` / data refs. From the visible label; empty label `field_<index>`.
*/
export function deriveFormFieldPayloadKey(label: string, index: number): string {
const trimmed = label.trim();
if (!trimmed) return `field_${index + 1}`;
const deaccent = trimmed.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
let s = deaccent
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
if (!s) return `field_${index + 1}`;
return s;
}

View file

@ -1 +1,10 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { FormNodeConfig } from './FormNodeConfig'; export { FormNodeConfig } from './FormNodeConfig';
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
export type { FormFieldOptionRow } from './formFieldOptionsUtils';
export {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';

View file

@ -0,0 +1,276 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Backend-driven case list for flow.switch (depends on value dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase {
operator: string;
value?: string | number | boolean;
}
function normalizeCase(c: unknown): SwitchCase {
if (c && typeof c === 'object' && 'operator' in (c as object)) {
const o = c as SwitchCase;
const v = o.value;
const safeValue: string | number | boolean | undefined =
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined;
return { operator: o.operator ?? 'eq', value: safeValue };
}
const fallbackValue: string | number | boolean | undefined =
typeof c === 'string' || typeof c === 'number' || typeof c === 'boolean' ? c : undefined;
return { operator: 'eq', value: fallbackValue };
}
function operatorsFromCatalog(
catalog: Record<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
function sanitizeCases(cases: SwitchCase[], operators: ConditionOperatorDef[]): SwitchCase[] {
if (!operators.length) return cases;
return cases.map((c) => {
const op = operators.find((o) => o.id === c.operator) ?? operators[0];
return {
operator: op.id,
value: op.needsValue ? c.value ?? '' : undefined,
};
});
}
function CaseValueInput({
caseItem,
opDef,
valueKind,
onChange,
t,
}: {
caseItem: SwitchCase;
opDef: ConditionOperatorDef | undefined;
valueKind: string;
onChange: (v: string | number) => void;
t: (key: string) => string;
}) {
const valueInput = opDef?.valueInput;
const val = caseItem.value;
if (
valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime'
) {
return (
<select
value={String(val ?? '')}
onChange={(e) => onChange(e.target.value)}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
}
return (
<input
type={
valueInput?.kind === 'number' || valueKind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(val ?? '')}
onChange={(e) =>
onChange(
valueInput?.kind === 'number' || valueKind === 'number'
? parseFloat(e.target.value) || 0
: e.target.value
)
}
placeholder={valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert')}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
);
}
export const CaseListEditor: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value')
: 'value';
const valueParam = allParams?.[dependsOn];
const ref: DataRef | null = isRef(valueParam) ? valueParam : null;
const rawCases = Array.isArray(value) ? value : [];
const cases: SwitchCase[] = rawCases.map(normalizeCase);
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
const [valueKind, setValueKind] = React.useState('unknown');
const [loading, setLoading] = React.useState(false);
const catalog = dataFlow?.conditionOperatorCatalog;
React.useEffect(() => {
if (!ref) {
const ops = operatorsFromCatalog(catalog, 'unknown');
setOperators(ops);
setValueKind('unknown');
return;
}
let cancelled = false;
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
if (cancelled) return;
setValueKind(vk);
setOperators(ops);
if (cases.length > 0) {
const next = sanitizeCases(cases, ops);
if (JSON.stringify(next) !== JSON.stringify(cases)) {
onChange(next);
}
}
};
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => applyMeta(meta.valueKind, meta.operators))
.catch(() => applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown')))
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown'));
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const setCases = (next: SwitchCase[]) => onChange(next);
const addCase = () => {
const opDef = operators[0];
setCases([
...cases,
{
operator: opDef?.id ?? 'eq',
value: opDef?.needsValue ? (valueKind === 'number' ? 0 : '') : undefined,
},
]);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst einen Wert im Data Picker wählen')}
</p>
</div>
);
}
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
{t('Lade Operatoren…')}
</div>
)}
{cases.map((c, i) => {
const opDef = operators.find((o) => o.id === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<select
value={c.operator}
onChange={(e) => {
const op = operators.find((o) => o.id === e.target.value);
const next = [...cases];
next[i] = {
operator: e.target.value,
value: op?.needsValue ? cases[i]?.value ?? '' : undefined,
};
setCases(next);
}}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
{needsValue && (
<CaseValueInput
caseItem={c}
opDef={opDef}
valueKind={valueKind}
t={t}
onChange={(v) => {
const next = [...cases];
next[i] = { ...next[i], value: v };
setCases(next);
}}
/>
)}
<button
type="button"
onClick={() => setCases(cases.filter((_, j) => j !== i))}
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
>
×
</button>
</div>
);
})}
<button
type="button"
onClick={addCase}
disabled={loading || operators.length === 0}
style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}
>
{t('Fall hinzufügen')}
</button>
</div>
);
};

View file

@ -0,0 +1,395 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* clickupList hierarchical ClickUp list picker via connector browse API.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { fetchBrowse, type BrowseEntry } from '../../../../api/workflowAutomationApi';
import { fetchClickupList } from '../../../../api/clickupApi';
import type { FieldRendererProps } from './index';
import {
clickupBrowseParentPath,
formatListPickerValue,
isClickupContainerEntry,
isClickupListEntry,
parseClickupListPath,
resolveListPathFromValue,
} from './clickupPathUtils';
const CLICKUP_PURPLE = '#7B68EE';
const glassPanel: React.CSSProperties = {
marginTop: 6,
borderRadius: 10,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255, 255, 255, 0.72)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
boxShadow:
'0 4px 24px rgba(123, 104, 238, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.5) inset',
padding: 8,
};
const glassTrigger: React.CSSProperties = {
display: 'flex',
width: '100%',
alignItems: 'stretch',
borderRadius: 8,
border: '1px solid rgba(123, 104, 238, 0.4)',
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(123,104,238,0.08) 100%)',
boxShadow: '0 0 12px rgba(123, 104, 238, 0.15)',
overflow: 'hidden',
};
export const ClickUpListPicker: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
instanceId,
request,
onPatchParams,
nodeType,
}) => {
const { t } = useLanguage();
const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference';
const connectionReference = (allParams?.[dependsOn] as string | undefined) || '';
const hasConnection = !!connectionReference && typeof connectionReference === 'string';
const [panelOpen, setPanelOpen] = useState(false);
const [browsePath, setBrowsePath] = useState('/');
const [items, setItems] = useState<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pickedLabel, setPickedLabel] = useState<string | null>(null);
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
const loadBrowse = useCallback(
async (path: string) => {
if (!request || !instanceId || !connectionReference) return;
setLoading(true);
setError(null);
try {
const res = await fetchBrowse(request, connectionReference, 'clickup', path);
setItems(res.items);
setBrowsePath(res.path || path);
} catch (err: unknown) {
setItems([]);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[request, instanceId, connectionReference],
);
useEffect(() => {
if (!panelOpen || !hasConnection) return;
void loadBrowse(browsePath);
}, [panelOpen, hasConnection, browsePath, loadBrowse]);
useEffect(() => {
if (!strVal) {
setPickedLabel(null);
return;
}
const pathFromVal = resolveListPathFromValue(strVal, param.name);
if (pathFromVal) {
const parsed = parseClickupListPath(pathFromVal);
if (parsed.listId && request && connectionReference) {
let cancelled = false;
fetchClickupList(request, connectionReference, parsed.listId)
.then((data) => {
if (cancelled) return;
const name = typeof data.name === 'string' ? data.name : null;
setPickedLabel(name || parsed.listId || strVal);
})
.catch(() => {
if (!cancelled) setPickedLabel(parsed.listId || strVal);
});
return () => {
cancelled = true;
};
}
setPickedLabel(parsed.listId || strVal);
return;
}
if (param.name === 'listId' && strVal && request && connectionReference) {
let cancelled = false;
fetchClickupList(request, connectionReference, strVal)
.then((data) => {
if (cancelled) return;
setPickedLabel(typeof data.name === 'string' ? data.name : strVal);
})
.catch(() => {
if (!cancelled) setPickedLabel(strVal);
});
return () => {
cancelled = true;
};
}
setPickedLabel(strVal);
}, [strVal, param.name, request, connectionReference]);
const shouldPatchTeamId =
nodeType === 'clickup.searchTasks' || Object.prototype.hasOwnProperty.call(allParams ?? {}, 'teamId');
const selectList = useCallback(
(entry: BrowseEntry) => {
const listPath = entry.path;
const stored = formatListPickerValue(listPath, param.name);
const { teamId, listId } = parseClickupListPath(listPath);
if (shouldPatchTeamId && onPatchParams && teamId) {
const patch: Record<string, unknown> = { [param.name]: stored };
patch.teamId = teamId;
onPatchParams(patch);
} else {
onChange(stored);
}
setPickedLabel(entry.name || listId || stored);
setPanelOpen(false);
},
[param.name, shouldPatchTeamId, onPatchParams, onChange],
);
const navigateInto = useCallback((entry: BrowseEntry) => {
if (!isClickupContainerEntry(entry.metadata, entry.isFolder)) return;
setBrowsePath(entry.path);
}, []);
const goUp = useCallback(() => {
setBrowsePath((p) => clickupBrowseParentPath(p));
}, []);
const clearSelection = useCallback(() => {
if (shouldPatchTeamId && onPatchParams) {
const patch: Record<string, unknown> = { [param.name]: '' };
if (nodeType === 'clickup.searchTasks') {
patch.teamId = '';
}
onPatchParams(patch);
} else {
onChange('');
}
setPickedLabel(null);
}, [shouldPatchTeamId, onPatchParams, onChange, param.name, nodeType]);
const triggerLabel = strVal
? pickedLabel ?? '…'
: t('ClickUp-Liste wählen');
const breadcrumb =
browsePath === '/'
? t('Workspaces')
: browsePath.replace(/^\/team\//, '').replace(/\//g, ' ');
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>
{param.description || param.name}
</label>
{!request || !instanceId ? (
<div style={{ fontSize: 11, color: '#888' }}>
{t('Listen-Browser nicht verfügbar (keine API-Anbindung).')}
</div>
) : (
<>
<div style={{ ...glassTrigger, opacity: hasConnection ? 1 : 0.55 }}>
<button
type="button"
disabled={!hasConnection}
onClick={() => {
if (!hasConnection) return;
setPanelOpen((o) => {
if (!o) setBrowsePath('/');
return !o;
});
}}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: hasConnection ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
color: 'var(--text-primary, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{hasConnection ? triggerLabel : t('Zuerst {field} wählen', { field: dependsOn })}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Auswahl aufheben')}
aria-label={t('Auswahl aufheben')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearSelection();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid rgba(123, 104, 238, 0.25)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && hasConnection && (
<div style={glassPanel}>
{error && (
<div style={{ fontSize: 11, color: '#c00', marginBottom: 6 }}>{error}</div>
)}
<div
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
{browsePath !== '/' && (
<button
type="button"
onClick={goUp}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
{t('Hoch')}
</button>
)}
<button
type="button"
onClick={() => loadBrowse(browsePath)}
title={t('Neu laden')}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
</button>
<span style={{ fontSize: 11, color: '#555', flex: 1, minWidth: 0 }}>
{breadcrumb}
</span>
</div>
<div
style={{
maxHeight: 220,
overflow: 'auto',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.2)',
background: 'rgba(255, 255, 255, 0.85)',
}}
>
{loading && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>{t('Lade')}</div>
)}
{!loading && items.length === 0 && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>
{t('Keine Einträge')}
</div>
)}
{!loading &&
items.map((item) => {
const isList = isClickupListEntry(item.metadata);
const canNavigate = isClickupContainerEntry(item.metadata, item.isFolder);
return (
<div
key={item.path}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
borderBottom: '1px solid rgba(123, 104, 238, 0.08)',
fontSize: 12,
}}
>
<span
role="button"
tabIndex={0}
onClick={() => {
if (isList) selectList(item);
else if (canNavigate) navigateInto(item);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (isList) selectList(item);
else if (canNavigate) navigateInto(item);
}}
style={{
flex: 1,
cursor: isList || canNavigate ? 'pointer' : 'default',
userSelect: 'none',
}}
title={isList ? t('Liste wählen') : canNavigate ? t('Öffnen') : undefined}
>
{isList ? '📋' : canNavigate ? '📁' : '·'} {item.name}
</span>
{isList && (
<button
type="button"
onClick={() => selectList(item)}
style={{
padding: '2px 8px',
borderRadius: 6,
border: `1px solid ${CLICKUP_PURPLE}`,
background: CLICKUP_PURPLE,
color: '#fff',
cursor: 'pointer',
fontSize: 11,
boxShadow: '0 0 8px rgba(123, 104, 238, 0.45)',
}}
>
{t('Wählen')}
</button>
)}
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
};

View file

@ -0,0 +1,225 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition {
type: 'condition';
operator: string;
value?: string | number;
/** Legacy — ignored when Item is set */
ref?: DataRef | null;
}
function parseCondition(v: unknown): StructuredCondition {
if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') {
const c = v as StructuredCondition;
return { type: 'condition', operator: c.operator ?? 'eq', value: c.value };
}
return { type: 'condition', operator: 'eq', value: '' };
}
function operatorsFromCatalog(
catalog: Record<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
export const ConditionEditor: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'Item')
: 'Item';
const itemRef = allParams?.[dependsOn];
const ref: DataRef | null = isRef(itemRef) ? itemRef : null;
const cond = parseCondition(value);
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
const [valueKind, setValueKind] = React.useState('unknown');
const [loading, setLoading] = React.useState(false);
const catalog = dataFlow?.conditionOperatorCatalog;
React.useEffect(() => {
if (!ref) {
setOperators([]);
setValueKind('unknown');
return;
}
let cancelled = false;
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
if (cancelled) return;
setValueKind(vk);
setOperators(ops);
const valid = ops.some((o) => o.id === cond.operator);
if (!valid && ops.length > 0) {
const first = ops[0];
onChange({
type: 'condition',
operator: first.id,
value: first.needsValue ? cond.value ?? '' : undefined,
});
}
};
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => {
applyMeta(meta.valueKind, meta.operators);
})
.catch(() => {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset operators when Item ref changes
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const currentOp = operators.find((o) => o.id === cond.operator) ?? operators[0];
const needsValue = currentOp?.needsValue ?? true;
const valueInput = currentOp?.valueInput;
const setCondition = (next: StructuredCondition) => {
onChange(next);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst ein Item im Data Picker wählen')}
</p>
</div>
);
}
const handleOperatorChange = (opId: string) => {
const opDef = operators.find((o) => o.id === opId);
setCondition({
type: 'condition',
operator: opId,
value: opDef?.needsValue ? cond.value ?? '' : undefined,
});
};
const handleValueChange = (v: string | number) => {
const kind = valueInput?.kind;
const parsed =
kind === 'number' || valueKind === 'number' ? parseFloat(String(v)) || 0 : String(v);
setCondition({ type: 'condition', operator: cond.operator, value: parsed });
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<ConditionRow>
<label>{t('Vergleich')}</label>
<select
value={cond.operator}
onChange={(e) => handleOperatorChange(e.target.value)}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</ConditionRow>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>{t('Lade Operatoren…')}</div>
)}
{needsValue && (
<ConditionRow>
<label>{t('Wert')}</label>
{valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime' ? (
<select
value={String(cond.value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
type={
valueInput?.kind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(cond.value ?? '')}
onChange={(e) =>
handleValueChange(
valueInput?.kind === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
)
}
placeholder={
valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert eingeben')
}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
</ConditionRow>
)}
</div>
);
};
const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6, fontSize: 12 }}>
{children}
</div>
);

View file

@ -0,0 +1,374 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* One place to configure context.setContext rows: target key, then either
* upstream picker, a fixed literal, or a human task.
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
type ValueSource = 'pickUpstream' | 'literal' | 'humanTask';
export interface ContextAssignmentRow {
contextKey: string;
valueSource: ValueSource;
/** Single resolved ref (server resolves { type: ref } to a value). */
upstreamRef?: DataRef | SystemVarRef | null;
/** Optional dotted path under the picked value, or under the wire payload (expert). */
sourcePath?: string;
literal?: string;
taskTitle?: string;
taskDescription?: string;
mode?: 'set' | 'setIfEmpty' | 'append' | 'increment';
valueType?: string;
}
function defaultRow(): ContextAssignmentRow {
return {
contextKey: '',
valueSource: 'literal',
literal: '',
mode: 'set',
valueType: 'str',
};
}
function legacyEntryToRow(
e: Record<string, unknown>,
globalPick: unknown,
): ContextAssignmentRow {
const am = String(e.assignmentMode || 'direct');
let valueSource: ValueSource = 'literal';
if (am === 'fromUpstream') valueSource = 'pickUpstream';
else if (am === 'humanTask') valueSource = 'humanTask';
const sourcePathStr = typeof e.sourcePath === 'string' ? e.sourcePath : '';
let upstream: DataRef | SystemVarRef | undefined;
if (isRef(e.upstreamRef) || isSystemVar(e.upstreamRef)) {
upstream = e.upstreamRef as DataRef | SystemVarRef;
} else if (
am === 'fromUpstream' &&
!sourcePathStr.trim() &&
(isRef(globalPick) || isSystemVar(globalPick))
) {
upstream = globalPick as DataRef | SystemVarRef;
}
return {
contextKey: typeof e.contextKey === 'string' ? e.contextKey : typeof e.key === 'string' ? e.key : '',
valueSource,
upstreamRef: upstream,
sourcePath: sourcePathStr,
literal: e.literal != null ? String(e.literal) : e.value != null ? String(e.value) : '',
taskTitle: typeof e.taskTitle === 'string' ? e.taskTitle : '',
taskDescription: typeof e.taskDescription === 'string' ? e.taskDescription : '',
mode: (e.mode as ContextAssignmentRow['mode']) || 'set',
valueType: typeof e.valueType === 'string' ? e.valueType : typeof e.type === 'string' ? e.type : 'str',
};
}
function normalizeRows(raw: unknown, allParams?: Record<string, unknown>): ContextAssignmentRow[] {
if (Array.isArray(raw) && raw.length > 0) {
return raw.map((r) => {
if (!r || typeof r !== 'object') return defaultRow();
const o = r as Record<string, unknown>;
let valueSource = o.valueSource as ValueSource | undefined;
if (!valueSource && o.assignmentMode === 'fromUpstream') valueSource = 'pickUpstream';
else if (!valueSource && o.assignmentMode === 'humanTask') valueSource = 'humanTask';
else if (!valueSource) valueSource = 'literal';
return {
contextKey: typeof o.contextKey === 'string' ? o.contextKey : typeof o.key === 'string' ? o.key : '',
valueSource,
upstreamRef: (isRef(o.upstreamRef) || isSystemVar(o.upstreamRef) ? o.upstreamRef : undefined) as
| DataRef
| SystemVarRef
| undefined,
sourcePath: typeof o.sourcePath === 'string' ? o.sourcePath : '',
literal: o.literal != null ? String(o.literal) : o.value != null ? String(o.value) : '',
taskTitle: typeof o.taskTitle === 'string' ? o.taskTitle : '',
taskDescription: typeof o.taskDescription === 'string' ? o.taskDescription : '',
mode: (o.mode as ContextAssignmentRow['mode']) || 'set',
valueType: typeof o.valueType === 'string' ? o.valueType : typeof o.type === 'string' ? o.type : 'str',
};
});
}
const g = allParams;
if (g && Array.isArray(g.entries) && g.entries.length > 0) {
const globalPick = g.upstreamPick;
return (g.entries as Record<string, unknown>[]).map((e) => legacyEntryToRow(e, globalPick));
}
if (g) {
const tk = String(g.targetKey || '').trim();
const globalPick = g.upstreamPick;
if (
tk &&
globalPick !== undefined &&
globalPick !== null &&
!(typeof globalPick === 'string' && !globalPick.trim()) &&
!(typeof globalPick === 'object' && globalPick !== null && Object.keys(globalPick).length === 0)
) {
const ups =
isRef(globalPick) || isSystemVar(globalPick) ? (globalPick as DataRef | SystemVarRef) : undefined;
return [
{
contextKey: tk,
valueSource: 'pickUpstream' as const,
upstreamRef: ups,
sourcePath: '',
literal: '',
taskTitle: '',
taskDescription: '',
mode: 'set',
valueType: 'str',
},
];
}
}
return [defaultRow()];
}
const MODES: Array<{ id: NonNullable<ContextAssignmentRow['mode']>; labelDe: string }> = [
{ id: 'set', labelDe: 'setzen' },
{ id: 'setIfEmpty', labelDe: 'setzen wenn leer' },
{ id: 'append', labelDe: 'anhängen' },
{ id: 'increment', labelDe: 'addieren' },
];
const TYPES = ['str', 'int', 'float', 'bool', 'object', 'list'] as const;
const ROW_BOX: React.CSSProperties = {
border: '1px solid #ddd',
borderRadius: 6,
padding: 8,
marginBottom: 8,
background: '#fafafa',
};
const CHIP_STYLE: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginTop: 4,
};
const REMOVE_BTN: React.CSSProperties = {
padding: '0 6px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
marginLeft: 'auto',
};
export const ContextAssignmentsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const rows = normalizeRows(value, allParams);
const [pickerRow, setPickerRow] = React.useState<number | null>(null);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const setRows = (next: ContextAssignmentRow[]) => {
onChange(next.length ? next : [defaultRow()]);
};
const setRow = (idx: number, patch: Partial<ContextAssignmentRow>) => {
const next = [...rows];
next[idx] = { ...next[idx], ...patch };
setRows(next);
};
const addRow = () => setRows([...rows, defaultRow()]);
const removeRow = (idx: number) => {
if (rows.length <= 1) {
onChange([defaultRow()]);
return;
}
setRows(rows.filter((_, i) => i !== idx));
};
const labelForRef = (ref: DataRef | SystemVarRef): string => {
if (isSystemVar(ref)) {
return t('System') + `: ${ref.variable}`;
}
const nodeLabel =
dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
) ?? ref.nodeId;
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
return pathStr ? `${nodeLabel}${pathStr}` : nodeLabel;
};
const onPickRef = (idx: number, picked: DataRef | SystemVarRef) => {
if (!isRef(picked) && !isSystemVar(picked)) return;
setRow(idx, { upstreamRef: picked });
setPickerRow(null);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{rows.map((row, idx) => (
<div key={idx} style={ROW_BOX}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="text"
placeholder={t('Ziel-Schlüssel im Kontext')}
value={row.contextKey}
onChange={(e) => setRow(idx, { contextKey: e.target.value })}
style={{ flex: '2 1 140px', minWidth: 120, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<select
value={row.valueSource}
onChange={(e) => {
const vs = e.target.value as ValueSource;
const patch: Partial<ContextAssignmentRow> = { valueSource: vs };
if (vs === 'literal') patch.upstreamRef = undefined;
if (vs === 'pickUpstream') patch.literal = '';
setRow(idx, patch);
}}
style={{ flex: '1 1 160px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="pickUpstream">{t('Wert aus Daten-Picker')}</option>
<option value="literal">{t('Fester Wert')}</option>
<option value="humanTask">{t('Benutzer setzt Wert (Task)')}</option>
</select>
<select
value={row.mode || 'set'}
onChange={(e) => setRow(idx, { mode: e.target.value as ContextAssignmentRow['mode'] })}
style={{ flex: '1 1 120px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{MODES.map((m) => (
<option key={m.id} value={m.id}>
{m.labelDe}
</option>
))}
</select>
<select
value={row.valueType || 'str'}
onChange={(e) => setRow(idx, { valueType: e.target.value })}
style={{ flex: '0 1 90px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{tp}
</option>
))}
</select>
<button type="button" style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }} onClick={() => removeRow(idx)}>
×
</button>
</div>
{row.valueSource === 'pickUpstream' && (
<div>
{row.upstreamRef && (isRef(row.upstreamRef) || isSystemVar(row.upstreamRef)) && (
<div style={CHIP_STYLE}>
<span style={{ flex: 1, color: '#2d6a2d' }}>{labelForRef(row.upstreamRef)}</span>
<button type="button" style={REMOVE_BTN} onClick={() => setRow(idx, { upstreamRef: undefined })} title={t('Entfernen')}>
×
</button>
</div>
)}
<button
type="button"
onClick={() => setPickerRow(idx)}
disabled={!hasSources}
style={{
marginTop: 4,
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #1c5fb5',
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{hasSources ? t('Datenquelle wählen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
<input
type="text"
placeholder={t('Optional: Zusatz-Pfad (z. B. payload.status)')}
value={row.sourcePath || ''}
onChange={(e) => setRow(idx, { sourcePath: e.target.value })}
style={{ width: '100%', marginTop: 6, padding: '4px 6px', borderRadius: 4, border: '1px dashed #aaa', fontSize: 11 }}
/>
</div>
)}
{row.valueSource === 'literal' && (
<input
type="text"
placeholder={t('Wert (oder JSON für object/list)')}
value={row.literal ?? ''}
onChange={(e) => setRow(idx, { literal: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
{row.valueSource === 'humanTask' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
type="text"
placeholder={t('Titel der Aufgabe (optional)')}
value={row.taskTitle || ''}
onChange={(e) => setRow(idx, { taskTitle: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<textarea
placeholder={t('Beschreibung für den Bearbeiter (optional)')}
value={row.taskDescription || ''}
onChange={(e) => setRow(idx, { taskDescription: e.target.value })}
rows={2}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
/>
</div>
)}
</div>
))}
<button type="button" onClick={addRow} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>
{t('Zuweisung hinzufügen')}
</button>
{dataFlow && pickerRow != null && (
<DataPicker
open
onClose={() => setPickerRow(null)}
onPick={(picked) => onPickRef(pickerRow, picked)}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType="Any"
/>
)}
</div>
);
};

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* ContextBuilderRenderer multi-select context binding for AI nodes. * ContextBuilderRenderer multi-select context binding for AI nodes.
* *
@ -11,7 +13,7 @@
import React from 'react'; import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker'; import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef'; import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
@ -52,7 +54,7 @@ const REMOVE_BTN: React.CSSProperties = {
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useWorkflowDataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false); const [pickerOpen, setPickerOpen] = React.useState(false);
const dragIndex = React.useRef<number | null>(null); const dragIndex = React.useRef<number | null>(null);

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* DataRefRenderer Pick-not-Push attribute binding using the existing * DataRefRenderer Pick-not-Push attribute binding using the existing
* hierarchical DataPicker. * hierarchical DataPicker.
@ -10,14 +12,14 @@
import React from 'react'; import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker'; import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef'; import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useWorkflowDataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false); const [pickerOpen, setPickerOpen] = React.useState(false);
const currentRef = isRef(value) ? (value as DataRef) : null; const currentRef = isRef(value) ? (value as DataRef) : null;

View file

@ -1,9 +1,11 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* FeatureInstancePicker renderer for frontendType="featureInstance". * FeatureInstancePicker renderer for frontendType="featureInstance".
* *
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered * Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via * by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
* GET /api/workflows/{instanceId}/options/feature.instance?featureCode=<code> * GET /api/workflow-automation/options/feature.instance?featureCode=<code>
* *
* Behavior matches the rest of the editor: * Behavior matches the rest of the editor:
* - 0 results -> hint to create a feature instance for this mandate * - 0 results -> hint to create a feature instance for this mandate
@ -42,7 +44,7 @@ export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
request({ request({
url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`, url: `/api/workflow-automation/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
method: 'get', method: 'get',
}) })
.then((res: unknown) => { .then((res: unknown) => {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens. * TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway). * Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
@ -5,11 +7,11 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker'; import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef'; import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/WorkflowFlowEditor.module.css';
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g; const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
@ -60,7 +62,7 @@ function _parseTokensInTemplate(
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useWorkflowDataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);

View file

@ -0,0 +1,269 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* userFileFolder FormGeneratorTree embedded: combobox-style trigger + expandable tree.
*/
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { FaFolderPlus } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { usePrompt } from '../../../../hooks/usePrompt';
import { getFolderTree, createFolder } from '../../../../api/fileApi';
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
import type { FieldRendererProps } from './index';
export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => {
const { t } = useLanguage();
const { prompt, PromptDialog } = usePrompt();
const [panelOpen, setPanelOpen] = useState(false);
/** Remount embedded tree after create/rename elsewhere */
const [treeRefreshKey, setTreeRefreshKey] = useState(0);
const [creating, setCreating] = useState(false);
/** Display name for saved folderId (resolved from API when graph loads). */
const [pickedName, setPickedName] = useState<string | null>(null);
const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
const strVal = typeof value === 'string' ? value : '';
const rootSelected = strVal === '';
useEffect(() => {
if (!strVal) {
setPickedName(null);
return;
}
if (!request) return;
let cancelled = false;
getFolderTree(request, 'me')
.then((folders) => {
if (cancelled) return;
const f = folders.find((x) => x.id === strVal);
setPickedName(f?.name ?? null);
})
.catch(() => {
if (!cancelled) setPickedName(null);
});
return () => {
cancelled = true;
};
}, [strVal, request]);
const handleNodeClick = useCallback(
(node: TreeNode) => {
if (node.type === 'folder') {
setPickedName(node.name);
onChange(node.id);
setPanelOpen(false);
}
},
[onChange],
);
const clearFolder = useCallback(() => {
onChange('');
setPickedName(null);
}, [onChange]);
const triggerLabel = strVal ? (pickedName ?? '…') : t('Wähle einen Zielordner');
const handleCreateFolder = useCallback(async () => {
if (!request || creating) return;
const parentHint = strVal && pickedName ? ` („${pickedName}“)` : strVal ? '' : ' (Stamm)';
const entered = await prompt(`Ordnername${parentHint}:`, {
title: 'Neuer Ordner',
placeholder: 'Ordnername',
confirmLabel: t('Anlegen'),
});
const trimmed = entered?.trim();
if (!trimmed) return;
setCreating(true);
try {
const parentId = strVal || null;
const folder = await createFolder(request, trimmed, parentId);
setPickedName(folder.name);
onChange(folder.id);
setTreeRefreshKey((k) => k + 1);
} catch {
// stay silent in minimal UI; devtools / global handler may log
} finally {
setCreating(false);
}
}, [request, creating, strVal, pickedName, prompt, onChange, t]);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{!request && (
<div style={{ fontSize: 11, color: '#888' }}>{t('Ordnerliste nicht verfügbar (keine API-Anbindung).')}</div>
)}
{request && (
<>
<div
style={{
display: 'flex',
width: '100%',
alignItems: 'stretch',
borderRadius: 6,
border: '1px solid var(--color-border, #cbd5e1)',
background: 'var(--table-header-bg, #f1f5f9)',
overflow: 'hidden',
marginBottom: panelOpen ? 6 : 0,
}}
>
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 12,
textAlign: 'left',
color: 'var(--color-text, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{triggerLabel}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Zielordner entfernen (Stamm — Meine Dateien)')}
aria-label={t('Zielordner entfernen')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearFolder();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid var(--color-border, #cbd5e1)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--color-text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && (
<div
style={{
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--color-bg, #fff)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'stretch',
borderBottom: '1px solid var(--color-border, #e2e8f0)',
background: 'var(--table-header-bg, #f8fafc)',
}}
>
<div
role="button"
tabIndex={0}
onClick={() => {
clearFolder();
setPanelOpen(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearFolder();
setPanelOpen(false);
}
}}
style={{
flex: 1,
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
minHeight: 36,
background: rootSelected ? 'rgba(37, 99, 235, 0.12)' : 'transparent',
}}
>
{t('Stamm — Meine Dateien')}
</div>
<button
type="button"
aria-label={t('Neuen Ordner erstellen')}
title={
creating
? t('Wird angelegt…')
: strVal
? `Unterordner von: ${pickedName ?? '…'}`
: 'Unter dem Stamm (oberste Ebene)'
}
disabled={creating}
onClick={(e) => {
e.stopPropagation();
void handleCreateFolder();
}}
style={{
flexShrink: 0,
width: 40,
minHeight: 36,
alignSelf: 'stretch',
border: 'none',
borderLeft: '1px solid var(--color-border, #e2e8f0)',
background: 'transparent',
cursor: creating ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--primary-color, #2563eb)',
opacity: creating ? 0.5 : 1,
}}
>
<FaFolderPlus size={14} aria-hidden />
</button>
</div>
<FormGeneratorTree
key={`user-folder-tree-${treeRefreshKey}`}
provider={provider}
ownership="own"
compact
allowCreateFolder={false}
showFilter={false}
emptyMessage={t('Noch keine Ordner')}
onNodeClick={handleNodeClick}
embedMaxHeight={240}
hideRowActionButtons
hideSectionHeader
enableDragDrop
/>
</div>
)}
<PromptDialog />
</>
)}
</div>
);
};

View file

@ -0,0 +1,30 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { describe, expect, it } from 'vitest';
import {
clickupBrowseParentPath,
formatListPickerValue,
parseClickupListPath,
} from './clickupPathUtils';
describe('clickupPathUtils', () => {
it('parseClickupListPath extracts team and list ids', () => {
expect(parseClickupListPath('/team/abc/list/xyz')).toEqual({
teamId: 'abc',
listId: 'xyz',
});
expect(parseClickupListPath('')).toEqual({});
});
it('formatListPickerValue stores path or listId by param name', () => {
const path = '/team/abc/list/xyz';
expect(formatListPickerValue(path, 'pathQuery')).toBe(path);
expect(formatListPickerValue(path, 'listId')).toBe('xyz');
});
it('clickupBrowseParentPath walks up hierarchy', () => {
expect(clickupBrowseParentPath('/')).toBe('/');
expect(clickupBrowseParentPath('/team/t1')).toBe('/');
expect(clickupBrowseParentPath('/team/t1/space/s1')).toBe('/team/t1');
});
});

View file

@ -0,0 +1,63 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** Parse virtual ClickUp list paths: /team/{teamId}/list/{listId} */
const LIST_PATH_RE = /^\/team\/([^/]+)\/list\/([^/]+)$/;
export function parseClickupListPath(path: string): { teamId?: string; listId?: string } {
const p = (path || '').trim();
const m = p.match(LIST_PATH_RE);
if (!m) return {};
return { teamId: m[1], listId: m[2] };
}
/** Store path for pathQuery; raw list id for listId param. */
export function formatListPickerValue(listPath: string, paramName: string): string {
const { listId } = parseClickupListPath(listPath);
if (paramName === 'listId' && listId) return listId;
return listPath;
}
/** Resolve list path from stored value (path or legacy raw id). */
export function resolveListPathFromValue(value: string, paramName: string): string | null {
const v = (value || '').trim();
if (!v) return null;
if (LIST_PATH_RE.test(v)) return v;
if (paramName === 'listId' && /^[a-zA-Z0-9_-]+$/.test(v)) {
return null;
}
return v;
}
export function clickupBrowseParentPath(path: string): string {
const p = (path || '/').trim() || '/';
if (p === '/') return '/';
const folder = p.match(/^(\/team\/[^/]+\/space\/[^/]+)\/folder\/[^/]+$/);
if (folder) return folder[1];
const space = p.match(/^(\/team\/[^/]+)\/space\/[^/]+$/);
if (space) return space[1];
if (/^\/team\/[^/]+$/.test(p)) return '/';
const parts = p.split('/').filter(Boolean);
if (parts.length <= 1) return '/';
parts.pop();
return `/${parts.join('/')}`;
}
export function cuTypeFromEntry(metadata?: Record<string, unknown>): string {
const t = metadata?.cuType;
return typeof t === 'string' ? t : '';
}
export function isClickupListEntry(metadata?: Record<string, unknown>): boolean {
return cuTypeFromEntry(metadata) === 'list';
}
export function isClickupContainerEntry(
metadata: Record<string, unknown> | undefined,
isFolder: boolean,
): boolean {
const cu = cuTypeFromEntry(metadata);
if (cu === 'list') return false;
if (cu === 'team' || cu === 'space' || cu === 'folder') return true;
return isFolder && cu !== 'task';
}

View file

@ -1,13 +1,21 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Generic FrontendType renderer registry. * Generic FrontendType renderer registry.
* Maps frontendType strings to React components. * Maps frontendType strings to React components.
*/ */
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowApi'; import type { NodeTypeParameter } from '../../../../api/workflowAutomationApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../../api/workflowAutomationApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from '../form/formFieldOptionsUtils';
export interface FieldRendererProps { export interface FieldRendererProps {
param: NodeTypeParameter; param: NodeTypeParameter;
@ -17,6 +25,10 @@ export interface FieldRendererProps {
instanceId?: string; instanceId?: string;
request?: ApiRequestFunction; request?: ApiRequestFunction;
nodeType?: string; nodeType?: string;
/** Atomically merge several parameter keys (e.g. cron + schedule). */
onPatchParams?: (patch: Record<string, unknown>) => void;
/** Hide the prominent ``param.name`` line (e.g. Accordion header already shows it). */
hideAccordionTitle?: boolean;
} }
export type FieldRendererComponent = ComponentType<FieldRendererProps>; export type FieldRendererComponent = ComponentType<FieldRendererProps>;
@ -26,14 +38,26 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import React from 'react'; import React from 'react';
import { SchedulePlanner } from '../../../SchedulePlanner';
import {
buildCronFromSpec,
scheduleSpecFromParams,
scheduleSpecToPersistentJson,
type ScheduleSpec,
} from '../../../../utils/scheduleCron';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils'; import { toApiGraph } from '../shared/graphUtils';
import { postUpstreamPaths } from '../../../../api/workflowApi'; import { postUpstreamPaths } from '../../../../api/workflowAutomationApi';
import type { CanvasNode } from '../../editor/FlowCanvas'; import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer'; import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer'; import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker'; import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { ClickUpListPicker } from './ClickUpListPicker';
import { ConditionEditor } from './ConditionEditor';
import { CaseListEditor } from './CaseListEditor';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config'; import { getApiBaseUrl } from '../../../../../config/config';
@ -98,29 +122,145 @@ const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) =>
</div> </div>
); );
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { /** Backend may send `options: ["a","b"]` or `options: [{ value, label }, ...]` (e.g. context.extractContent). */
const options: string[] = function _normalizedSelectOptions(raw: unknown): Array<{ value: string; label: string }> {
(param.frontendOptions?.options as string[]) || (param.options as string[]) || []; if (!Array.isArray(raw)) return [];
const out: Array<{ value: string; label: string }> = [];
for (const item of raw) {
if (typeof item === 'string') {
out.push({ value: item, label: item });
continue;
}
if (item && typeof item === 'object' && 'value' in item) {
const rec = item as { value?: unknown; label?: unknown };
if (typeof rec.value === 'string') {
const label = typeof rec.label === 'string' && rec.label.length > 0 ? rec.label : rec.value;
out.push({ value: rec.value, label });
}
}
}
return out;
}
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange, hideAccordionTitle }) => {
const { t } = useLanguage();
const options = _normalizedSelectOptions(
param.frontendOptions?.options ?? param.options ?? []
);
const allowClear = !param.required;
const current = value === undefined || value === null || value === '' ? '' : String(value);
const groupId = `select-segment-${param.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
const titleId = `${groupId}-title`;
const descId = `${groupId}-desc`;
const showNameLine = !hideAccordionTitle;
const labelledBy = showNameLine
? param.description
? `${titleId} ${descId}`
: titleId
: param.description
? descId
: undefined;
return ( return (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> {showNameLine ? (
<select <div
value={typeof value === 'string' ? value : ''} id={titleId}
onChange={(e) => onChange(e.target.value)} style={{
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }} display: 'block',
fontSize: 12,
fontWeight: 700,
marginBottom: param.description ? 4 : 6,
color: 'var(--text-primary, #212529)',
letterSpacing: '0.01em',
}}
>
{param.name}
</div>
) : null}
{param.description ? (
<div
id={descId}
style={{
display: 'block',
fontSize: 12,
fontWeight: 400,
lineHeight: 1.35,
marginBottom: 6,
color: 'var(--text-secondary, #555)',
}}
>
{param.description}
</div>
) : null}
<div
role="radiogroup"
aria-labelledby={labelledBy ?? undefined}
aria-label={!labelledBy ? param.name : undefined}
aria-required={param.required ? true : undefined}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
alignItems: 'stretch',
}}
> >
<option value=""></option> {options.map((opt) => {
{options.map((opt) => ( const selected = current === opt.value;
<option key={opt} value={opt}>{opt}</option> return (
))} <button
</select> key={opt.value}
type="button"
role="radio"
aria-checked={selected}
title={
allowClear && selected
? t('Erneut klicken, um die Auswahl aufzuheben')
: undefined
}
onClick={() => {
if (allowClear && selected) {
onChange(undefined);
} else {
onChange(opt.value);
}
}}
style={{
flex: '1 1 auto',
minWidth: 'min(100%, 72px)',
maxWidth: '100%',
textAlign: 'center',
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.25,
borderRadius: 6,
border: selected
? '2px solid var(--primary-color, #0d6efd)'
: '1px solid var(--border-color, #ccc)',
background: selected
? 'var(--primary-soft-bg, rgba(13, 110, 253, 0.12))'
: 'var(--panel-subtle-bg, #f8f9fa)',
color: selected
? 'var(--primary-color, #0a58ca)'
: 'var(--text-primary, #212529)',
fontWeight: selected ? 600 : 400,
cursor: 'pointer',
boxShadow: selected ? 'inset 0 0 0 1px rgba(13, 110, 253, 0.15)' : 'none',
transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
}}
>
{opt.label}
</button>
);
})}
</div>
</div> </div>
); );
}; };
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] = const options = _normalizedSelectOptions(
(param.frontendOptions?.options as string[]) || (param.options as string[]) || []; param.frontendOptions?.options ?? param.options ?? []
);
const selected = Array.isArray(value) ? value : []; const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) => { const toggle = (opt: string) => {
const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt]; const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt];
@ -131,9 +271,9 @@ const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> <label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{options.map((opt) => ( {options.map((opt) => (
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}> <label key={opt.value} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} /> <input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt} {opt.label}
</label> </label>
))} ))}
</div> </div>
@ -162,7 +302,7 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => { const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useWorkflowDataFlow();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]); const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
const [loadError, setLoadError] = React.useState<string | null>(null); const [loadError, setLoadError] = React.useState<string | null>(null);
const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]); const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]);
@ -172,7 +312,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
if (!instanceId || !request) return; if (!instanceId || !request) return;
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : ''; const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
setLoadError(null); setLoadError(null);
request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' }) request({ url: `/api/workflow-automation/options/user.connection${qs}`, method: 'get' })
.then((res: unknown) => { .then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> }; const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label }))); setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
@ -190,7 +330,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
return; return;
} }
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections); const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId) postUpstreamPaths(request, graph, dataFlow.currentNodeId)
.then(({ paths }) => { .then(({ paths }) => {
const opts = paths const opts = paths
.filter( .filter(
@ -503,51 +643,42 @@ const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onCh
); );
}; };
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 FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const ctx = useAutomation2DataFlow(); const ctx = useWorkflowDataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes ? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' })); : FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = Array.isArray(value) ? value : []; const fields = Array.isArray(value) ? value : [];
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]); const addField = () => {
const idx = fields.length;
onChange([
...fields,
{ name: deriveFormFieldPayloadKey('', idx), type: 'text', label: '', required: false },
]);
};
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx)); const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
const updateField = (idx: number, field: string, val: unknown) => { const updateField = (idx: number, field: string, val: unknown) => {
const next = [...fields]; const next = [...fields];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val }; next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next); onChange(next);
}; };
const setFieldLabel = (idx: number, label: string) => {
const next = [...fields];
const row = { ...(next[idx] as Record<string, unknown>), label, name: deriveFormFieldPayloadKey(label, idx) };
next[idx] = row;
onChange(next);
};
const setTopFieldType = (idx: number, typeId: string) => {
const next = [...fields];
const cur = { ...(next[idx] as Record<string, unknown>) };
cur.type = typeId;
if (formFieldTypeHasConfigurableOptions(typeId)) {
cur.options = normalizeFormFieldOptions(cur.options);
}
next[idx] = cur;
onChange(next);
};
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd', width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff', fontSize: 12, boxSizing: 'border-box', background: '#fff',
@ -565,7 +696,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
type="text" type="text"
placeholder={t('Bezeichnung (Anzeigename)')} placeholder={t('Bezeichnung (Anzeigename)')}
value={String(f.label ?? '')} value={String(f.label ?? '')}
onChange={(e) => updateField(i, 'label', e.target.value)} onChange={(e) => setFieldLabel(i, e.target.value)}
style={{ ...inputStyle, flex: 1, fontWeight: 500 }} style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
/> />
<button <button
@ -575,21 +706,11 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }} style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
>×</button> >×</button>
</div> </div>
{/* Row 2: Name + Typ + Pflicht */} {/* Row 2: Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
<input
type="text"
placeholder="z.B. customerName"
value={String(f.name ?? '')}
onChange={(e) => updateField(i, 'name', e.target.value)}
style={inputStyle}
/>
</div>
<div> <div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div> <div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div>
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={selectStyle}> <select value={String(f.type ?? 'text')} onChange={(e) => setTopFieldType(i, e.target.value)} style={selectStyle}>
{fieldTypeOptions.map((ft) => ( {fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option> <option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))} ))}
@ -601,6 +722,14 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
Pflicht Pflicht
</label> </label>
</div> </div>
{formFieldTypeHasConfigurableOptions(String(f.type)) ? (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => updateField(i, 'options', opts)}
/>
</div>
) : null}
{String(f.type) === 'group' && ( {String(f.type) === 'group' && (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}> <div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div> <div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
@ -609,11 +738,16 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input <input
type="text" type="text"
placeholder={t('Name')} placeholder={t('Bezeichnung')}
value={String(sub.name ?? '')} value={String(sub.label ?? sub.name ?? '')}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, name: e.target.value }; nextFields[j] = {
...sub,
label,
name: deriveFormFieldPayloadKey(label, j),
};
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ ...inputStyle, flex: 1 }} style={{ ...inputStyle, flex: 1 }}
@ -621,8 +755,16 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<select <select
value={String(sub.type ?? 'text')} value={String(sub.type ?? 'text')}
onChange={(e) => { onChange={(e) => {
const typeId = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, type: e.target.value }; const subRow: Record<string, unknown> = {
...(nextFields[j] as Record<string, unknown>),
type: typeId,
};
if (formFieldTypeHasConfigurableOptions(typeId)) {
subRow.options = normalizeFormFieldOptions(subRow.options);
}
nextFields[j] = subRow;
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ ...selectStyle, flex: 1 }} style={{ ...selectStyle, flex: 1 }}
@ -640,12 +782,31 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }} style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
>×</button> >×</button>
</div> </div>
{formFieldTypeHasConfigurableOptions(String(sub.type)) ? (
<div style={{ marginTop: 6 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(sub.options)}
onChange={(opts) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, options: opts };
updateField(i, 'fields', nextFields);
}}
/>
</div>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
const j = nextFields.length;
nextFields.push({
name: deriveFormFieldPayloadKey('', j),
type: 'text',
label: '',
required: false,
});
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }} style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
@ -692,47 +853,38 @@ const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
); );
}; };
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, onPatchParams }) => {
const { t } = useLanguage(); const spec = React.useMemo(
() =>
scheduleSpecFromParams({
...(allParams ?? {}),
cron:
(typeof value === 'string' && value
? value
: typeof allParams?.cron === 'string'
? allParams.cron
: '') as string,
} as Record<string, unknown>),
[allParams, value]
);
const handlePlanner = React.useCallback(
(next: ScheduleSpec) => {
const cron = buildCronFromSpec(next);
const schedule = scheduleSpecToPersistentJson(next);
if (onPatchParams) onPatchParams({ cron, schedule });
else onChange(cron);
},
[onChange, onPatchParams]
);
return ( return (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> <label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
<input <SchedulePlanner value={spec} onChange={handlePlanner} />
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> </div>
); );
}; };
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const ConditionBuilder = ConditionEditor;
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 MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -913,11 +1065,13 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
hidden: HiddenInput, hidden: HiddenInput,
dataRef: DataRefRenderer, dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer, contextBuilder: ContextBuilderRenderer,
contextAssignments: ContextAssignmentsEditor,
userConnection: ConnectionPicker, userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker, featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker, sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker, sharepointFile: SharepointPathPicker,
clickupList: FolderPicker, userFileFolder: UserFileFolderPicker,
clickupList: ClickUpListPicker,
clickupTask: FolderPicker, clickupTask: FolderPicker,
caseList: CaseListEditor, caseList: CaseListEditor,
fieldBuilder: FieldBuilderEditor, fieldBuilder: FieldBuilderEditor,

View file

@ -1,154 +0,0 @@
/**
* If/Else node config - inline UI: source dropdown, operator (type-dependent), value.
* Kein Popup, alles in einer Zeile.
*/
import React from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef } from '../shared/dataRef';
import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMapping';
import { operatorsForType } from '../shared/conditionOperators';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition {
type: 'condition';
ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null;
operator: string;
value?: string | number;
}
function parseCondition(v: unknown): StructuredCondition | null {
if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') {
const c = v as StructuredCondition;
if (c.ref === null || isRef(c.ref)) return c;
}
return null;
}
export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const cond = parseCondition(params.condition);
const ref = cond?.ref ?? null;
const operator = cond?.operator ?? 'eq';
const value = cond?.value ?? '';
const fieldType = dataFlow ? getFieldType(ref, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown';
const operators = operatorsForType(fieldType);
const currentOp = operators.find((o) => o.value === operator) ?? operators[0];
const needsValue = currentOp?.needsValue ?? true;
const isMimeTypeRef =
ref && ref.path?.length >= 2 && ref.path[ref.path.length - 1] === 'mimeType';
const sourceNode = ref && dataFlow
? dataFlow.nodes.find((n: { id: string; type?: string; parameters?: Record<string, unknown> }) => n.id === ref.nodeId)
: null;
const mimeTypeOptions =
isMimeTypeRef && sourceNode?.type === 'input.upload' && sourceNode.parameters
? getMimeTypeOptionsFromUploadParams(sourceNode.parameters as Record<string, unknown>)
: [];
const setCondition = (next: StructuredCondition) => {
updateParam('condition', next);
};
const handleRefChange = (newRef: { type: 'ref'; nodeId: string; path: (string | number)[] } | null) => {
if (!newRef) {
setCondition({
type: 'condition',
ref: null,
operator: 'eq',
value: '',
});
return;
}
const newType = dataFlow ? getFieldType(newRef, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown';
const newOps = operatorsForType(newType);
setCondition({
type: 'condition',
ref: newRef,
operator: newOps[0]?.value ?? 'eq',
value: cond?.value ?? '',
});
};
const handleOperatorChange = (op: string) => {
const opDef = operators.find((o) => o.value === op);
setCondition({
type: 'condition',
ref: cond?.ref ?? null,
operator: op,
value: opDef?.needsValue ? value : undefined,
});
};
const handleValueChange = (v: string | number) => {
setCondition({
type: 'condition',
ref: cond?.ref ?? null,
operator,
value: fieldType === 'number' ? (parseFloat(String(v)) || 0) : String(v),
});
};
return (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>{t('Datenquelle')}</label>
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('Formularfeld wählen')} />
</div>
<div className={styles.ifElseConditionRow}>
<label>Vergleich</label>
<select value={operator} onChange={(e) => handleOperatorChange(e.target.value)}>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{needsValue && (
<div className={styles.ifElseConditionRow}>
<label>{t('Wert')}</label>
{mimeTypeOptions.length > 0 ? (
<select
value={String(value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
>
<option value="">{t('MIME-Typ wählen')}</option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
</option>
))}
</select>
) : (
<input
type={fieldType === 'number' ? 'number' : fieldType === 'date' ? 'date' : 'text'}
value={String(value ?? '')}
onChange={(e) =>
handleValueChange(
fieldType === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
)
}
placeholder={
fieldType === 'number'
? '0'
: fieldType === 'date'
? 'TT.MM.JJJJ'
: isMimeTypeRef
? t('z.B. application/pdf')
: t('z.B. ch')
}
/>
)}
</div>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more