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
# 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
* Centralized access to environment variables with fallbacks
* Configuration reads mandatory env vars set by .env (copied from config/env-*.env by CI).
*
* 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
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
};
const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
export const getApiTimeout = (): number => {
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
};
if (!_apiBaseUrl) {
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 getAppName = (): string => {
return import.meta.env.VITE_APP_NAME || 'PowerOn';
};
export const getApiBaseUrl = (): string => _apiBaseUrl;
export const getAppVersion = (): string => {
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,
};
export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# 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

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# 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

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# 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

View file

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

View file

@ -185,7 +185,10 @@
</div>
<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>
</body>

View file

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

View file

@ -153,7 +153,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> August 2025
<strong>Last Updated:</strong> May 2026
</div>
<div class="content-section">
@ -315,8 +315,13 @@
<h2>Contact Information</h2>
<p>If you have any questions about these Terms of Service, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> legal@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div>
</div>
@ -326,7 +331,7 @@
</div>
<div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
<p>&copy; 2026 PowerOn. All rights reserved.</p>
</div>
</div>
</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
*
@ -39,11 +41,12 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, 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 { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
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';
function App() {
// Load saved theme preference and set app name on app mount
@ -123,15 +126,20 @@ function App() {
</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) */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} />
<Route path="speech" element={<Navigate to="/" replace />} />
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */}
@ -165,13 +173,8 @@ function App() {
<Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Workspace + Automation2 Editor */}
{/* Workspace 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 */}
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
@ -218,7 +221,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<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="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />

View file

@ -1,25 +1,10 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
// api.ts
import axios from 'axios';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
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.
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
@ -44,52 +29,25 @@ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
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({
baseURL: getApiBaseUrl(),
baseURL: _baseUrl,
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 },
});
// 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(
async (config) => {
// Log backend information
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
// Add auth token if available (otherwise httpOnly cookies are used automatically)
const authToken = localStorage.getItem('authToken');
if (authToken && config.headers) {
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

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
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 api from '../api';
import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
@ -12,14 +14,30 @@ export interface LoginRequest {
}
export interface LoginResponse {
type: 'local_auth_success';
type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required';
accessToken?: string;
tokenType?: string;
authenticationAuthority?: string;
mfaToken?: string;
provisioningUri?: string;
label?: 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 {
username: string;
email: string;
@ -316,3 +334,36 @@ export async function logoutApi(): Promise<void> {
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';
// ============================================================================

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 { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { ApiRequestOptions } from '../hooks/useApi';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
@ -6,17 +8,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences {
schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
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
*
@ -14,8 +16,6 @@ import type {
InstancePermissions,
AccessLevel,
} from '../types/mandate';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
// =============================================================================
// MOCK DATA (Temporär bis Backend bereit)
// =============================================================================
@ -172,56 +172,11 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
}
try {
console.log('📡 featuresApi: Fetching /api/features/my');
const response = await api.get<FeaturesMyResponse>('/api/features/my');
// Get the actual data (response.data contains the FeaturesMyResponse)
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;
} catch (error) {
console.error('❌ featuresApi: Error fetching features:', error);
@ -239,7 +194,6 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
return [
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', 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';
// ============================================================================
@ -36,6 +38,7 @@ export interface PaginationParams {
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
}
export interface PaginatedResponse<T> {
@ -109,6 +112,7 @@ export async function fetchFiles(
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (params.owner) requestParams.owner = params.owner;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api';
import type { VoiceOption } from './voiceCatalogApi';
@ -71,6 +73,7 @@ export interface TeamsbotConfig {
triggerCooldownSeconds: number;
contextWindowSegments: number;
debugMode?: boolean;
avatarFileId?: string;
}
export interface TeamsbotSessionStats {
@ -84,6 +87,7 @@ export interface TeamsbotSessionStats {
export interface StartSessionRequest {
meetingLink: string;
botName?: string;
moduleId?: string;
connectionId?: string;
joinMode?: TeamsbotJoinMode;
sessionContext?: string;
@ -102,6 +106,7 @@ export interface ConfigUpdateRequest {
triggerCooldownSeconds?: number;
contextWindowSegments?: number;
debugMode?: boolean;
avatarFileId?: string;
}
// 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 });
}
/** 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)
// =========================================================================
@ -592,6 +604,9 @@ export interface MeetingModule {
defaultDirectorPrompts?: string;
goals?: string;
kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string;
}
@ -602,6 +617,7 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
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> {
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
*
@ -864,7 +866,14 @@ export async function syncPositionsToAccounting(
request: ApiRequestFunction,
instanceId: 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[] }> {
const submission = await request({
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,

View file

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

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* 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
*

View file

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

View file

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

View file

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

View file

@ -73,13 +73,12 @@
/* Connector grid (Step 0) */
.connectorGrid {
display: flex;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
flex-wrap: wrap;
}
.connectorCard {
flex: 1 1 140px;
display: flex;
flex-direction: column;
align-items: center;
@ -447,6 +446,22 @@
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 */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);

View file

@ -1,153 +1,55 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AddConnectionWizard
*
* Multi-step modal for adding a new connector with optional knowledge
* ingestion consent and per-connection preferences (§2.6).
*
* Steps:
* 0 Connector wählen
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
* Streamlined multi-step modal for adding a new connector.
* Steps are connector-type-aware:
* Base: Connector Consent Connect
* Microsoft: Connector Consent Admin Consent (optional) Connect
* Infomaniak: Connector Consent PAT Input (done)
*/
import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './AddConnectionWizard.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup';
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState {
step: 0 | 1 | 2 | 3;
currentStep: StepId;
connector: ConnectorType | null;
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> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
infomaniak: 'Infomaniak',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
};
// ---------------------------------------------------------------------------
// Cost estimate helper
// ---------------------------------------------------------------------------
/**
* 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).',
};
function _getSteps(connector: ConnectorType | null): StepId[] {
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
}
// ---------------------------------------------------------------------------
@ -157,11 +59,9 @@ function computeCostEstimate(
interface AddConnectionWizardProps {
open: boolean;
onClose: () => void;
onConnect: (
type: ConnectorType,
knowledgeEnabled: boolean,
prefs: KnowledgePreferences | null,
) => Promise<void>;
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
onMsftAdminConsent?: () => void;
isConnecting?: boolean;
}
@ -173,84 +73,93 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false,
}) => {
const { t } = useLanguage();
const [state, setState] = useState<WizardState>({
step: 0,
currentStep: 'connector',
connector: null,
knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS },
infomaniakToken: '',
adminConsentDone: false,
});
const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
const handleClose = () => {
reset();
onClose();
const handleClose = () => { 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 setConnector = (connector: ConnectorType) =>
setState(s => ({ ...s, connector, step: 1 }));
const setKnowledgeEnabled = (v: boolean) =>
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 goBack = () => {
const prevIdx = stepIndex - 1;
if (prevIdx >= 0) {
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
}
};
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;
await onConnect(
state.connector,
state.knowledgeEnabled,
state.knowledgeEnabled ? state.prefs : null,
);
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
} else {
await onConnect(state.connector, state.knowledgeEnabled);
}
reset();
onClose();
};
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return (
<Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
<Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
{/* Stepper */}
<div className={styles.stepper}>
{[0, 1, 2, 3].map(i => (
{steps.map((s, i) => (
<div
key={i}
key={s}
className={[
styles.stepDot,
state.step === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
stepIndex === i ? styles.stepDotActive : '',
stepIndex > i ? styles.stepDotDone : '',
].join(' ')}
>
{state.step > i ? <FaCheck size={10} /> : i + 1}
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step 0: Connector ---- */}
{state.step === 0 && (
{/* ---- Step: Connector ---- */}
{state.currentStep === 'connector' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
<p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => setConnector(type)}
onClick={() => selectConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -260,253 +169,119 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div>
)}
{/* ---- Step 1: Consent ---- */}
{state.step === 1 && (
{/* ---- Step: Consent ---- */}
{state.currentStep === 'consent' && (
<div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
<p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
{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') })}
</p>
<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>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
>
<FaCheck /> Ja, aufnehmen
<FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
</button>
<button
type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
{t('Überspringen')}
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
Zurück
</button>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div>
</div>
)}
{/* ---- Step 2: Preferences ---- */}
{state.step === 2 && (
{/* ---- Step: Infomaniak PAT ---- */}
{state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
<h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
<p className={styles.stepBody}>
{t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
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>
<input
type="password"
placeholder="pat_..."
value={state.infomaniakToken}
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
className={styles.patInput}
autoFocus
/>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button
type="button"
className={styles.navConnect}
onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? t('Verbinden…') : t('Verbinden')}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
{/* ---- Step: Connect ---- */}
{state.currentStep === 'connect' && (
<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.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryKey}>{t('Anbieter')}</span>
<span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Wissensdatenbank</span>
<span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
<span className={styles.summaryVal}>
{state.knowledgeEnabled ? 'Aktiv' : 'Nicht aktiv'}
{state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
</span>
</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>
{/* 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}>
<button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button
type="button"
className={styles.navConnect}
onClick={handleConnect}
onClick={handleFinalConnect}
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} />}
</button>
</div>

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* 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.
*

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ChatMessageList } from './ChatMessageList';
export type { ChatMessage } from './ChatMessageList';
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 { 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 { IoIosDownload } from 'react-icons/io';
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 type { ContentPreviewProps } from './ContentPreview';
export { UrlContentPreview } from './UrlContentPreview';

View file

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

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext';
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 * as XLSX from 'xlsx';
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';
interface HtmlRendererProps {

View file

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

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
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 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';
// @ts-ignore
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 { useLanguage } from '../../../providers/language/LanguageContext';
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 { 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 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 { renderAsync } from 'docx-preview';
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 { ImageRenderer } from './ImageRenderer';
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.
*/
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
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;
nodes: CanvasNode[];
connections: CanvasConnection[];
@ -19,6 +21,8 @@ export interface Automation2DataFlowContextValue {
systemVariables: Record<string, SystemVariable>;
/** Canonical form field types from the API — maps UI type id to portType primitive. */
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;
getAvailableSourceIds: () => string[];
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
@ -28,13 +32,13 @@ export interface Automation2DataFlowContextValue {
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
}
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
const WorkflowDataFlowContext = createContext<WorkflowDataFlowContextValue | null>(null);
export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null {
return useContext(Automation2DataFlowContext);
export function useWorkflowDataFlow(): WorkflowDataFlowContextValue | null {
return useContext(WorkflowDataFlowContext);
}
interface Automation2DataFlowProviderProps {
interface WorkflowDataFlowProviderProps {
node: CanvasNode | null;
nodes: CanvasNode[];
connections: CanvasConnection[];
@ -44,12 +48,13 @@ interface Automation2DataFlowProviderProps {
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
instanceId?: string;
request?: ApiRequestFunction;
children: React.ReactNode;
}
export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({
export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> = ({
node,
nodes,
connections,
@ -59,11 +64,12 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog = {},
systemVariables = {},
formFieldTypes = [],
conditionOperatorCatalog = {},
instanceId,
request,
children,
}) => {
const value = useMemo((): Automation2DataFlowContextValue | null => {
const value = useMemo((): WorkflowDataFlowContextValue | null => {
if (!node) return null;
const formTypeToPort: Record<string, string> = Object.fromEntries(
formFieldTypes.map((f) => [f.id, f.portType])
@ -120,6 +126,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog,
systemVariables,
formFieldTypes,
conditionOperatorCatalog,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
@ -127,11 +134,11 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
request,
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 (
<Automation2DataFlowContext.Provider value={value}>
<WorkflowDataFlowContext.Provider value={value}>
{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 { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import {
FaPlay,
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 { getUserDataCache } from '../../../utils/userCache';
import { Button } from '../../UiComponents/Button';
interface TargetInstanceOption {
id: string;
label: string;
const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
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 {
workflows: Automation2Workflow[];
workflows: WorkflowDefinition[];
currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onWorkflowSettings?: () => void;
onToggleChat?: () => void;
onToggleWorkspacePanel?: () => void;
workspacePanelOpen?: boolean;
saving: boolean;
executing: boolean;
hasNodes: boolean;
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
* parent can navigate the user to the first offending node. */
/** When set, required-field graph errors block a normal run; message is the
* run button tooltip. Click still fires `onExecuteBlockedClick` to focus
* the first offending node. */
executeBlockedReason?: string | null;
onExecuteBlockedClick?: () => void;
executeResult: ExecuteGraphResponse | null;
@ -44,15 +90,11 @@ interface CanvasHeaderProps {
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean;
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
onAutoLayout?: () => void;
/** Sysadmin-only: when true, NodeConfigPanel renders the static
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
canvasEdit?: CanvasHeaderCanvasEditProps;
}
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,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onWorkflowSettings,
onToggleChat,
onToggleWorkspacePanel,
workspacePanelOpen,
saving,
executing,
hasNodes,
@ -88,13 +134,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onSaveAsTemplate,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
verboseSchema,
onVerboseSchemaChange,
targetFeatureInstanceId,
onTargetInstanceChange,
targetInstanceOptions,
canvasEdit,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@ -109,38 +151,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
const _startNameEdit = useCallback(() => {
if (!currentWorkflowId || !onWorkflowRename) return;
setNameValue(currentWorkflow?.label || '');
setEditingName(true);
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const _commitNameEdit = useCallback(() => {
setEditingName(false);
const trimmed = nameValue.trim();
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
if (trimmed !== currentWorkflow?.label) {
onWorkflowRename(currentWorkflowId, trimmed);
}
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const zoomMenuRef = useRef<HTMLDivElement>(null);
const [zoomInputDraft, setZoomInputDraft] = useState('');
useEffect(() => {
if (editingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [editingName]);
const zp = canvasEdit?.zoomPercent;
if (zp !== undefined) setZoomInputDraft(String(zp));
}, [canvasEdit?.zoomPercent]);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
};
document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside);
@ -156,15 +180,106 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
[t]
);
const _titleHint =
onWorkflowRename && currentWorkflow
? `${currentWorkflow.label}${t('Klicken zum Umbenennen')}`
: currentWorkflow?.label;
const _panelOpen = workspacePanelOpen ?? false;
const _runAriaLabel = executing
? t('Ausführen…')
: 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 (
<div className={styles.canvasHeader}>
<div className={styles.canvasHeaderRow}>
<div className={styles.canvasHeaderContext}>
<div className={styles.canvasHeader} data-suppress-flow-node-hotkeys="">
<div
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
className={styles.canvasHeaderWorkflowSelect}
value={currentWorkflowId ?? ''}
@ -182,142 +297,53 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</option>
))}
</select>
<div className={styles.canvasHeaderTitleBlock}>
{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
<Button
type="button"
className={styles.retryButton}
onClick={onSave}
variant={_tb}
size={_ts}
icon={saving ? undefined : FaSave}
className={styles.canvasHeaderIconBtn}
loading={saving}
disabled={saving}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
>
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
{onAutoLayout && (
<button
type="button"
className={styles.retryButton}
onClick={onAutoLayout}
disabled={!hasNodes}
title={t('Knoten automatisch anordnen')}
>
<FaSitemap style={{ marginRight: '0.4rem' }} />
{t('Anordnen')}
</button>
)}
onClick={onSave}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
aria-label={t('Speichern')}
/>
<Button
type="button"
variant={_tb}
size={_ts}
icon={executing ? undefined : FaPlay}
loading={executing}
disabled={executing || !hasNodes}
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
aria-label={_runAriaLabel}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={_runTitle}
/>
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<button
<Button
type="button"
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
variant={_tb}
size={_ts}
icon={FaBookmark}
loading={templateSaving}
disabled={templateSaving}
onClick={() => setTemplateMenuOpen((p) => !p)}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
</button>
{t('Als Vorlage')}
</Button>
{templateMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => (
@ -325,7 +351,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
key={s}
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
onClick={() => {
onSaveAsTemplate(s);
setTemplateMenuOpen(false);
}}
role="menuitem"
>
{scopeLabels[s]}
@ -336,53 +365,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</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 && (
<label
className={styles.canvasHeaderSysadmin}
@ -392,14 +374,173 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
type="checkbox"
checked={!!verboseSchema}
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
style={{ margin: 0 }}
className={styles.canvasHeaderSysadminInput}
/>
{t('Schema-Details')}
</label>
)}
</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 && (
<div className={styles.canvasHeaderVersionRow}>
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
@ -418,108 +559,94 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
))}
</select>
<span
style={{
padding: '2px 8px',
borderRadius: 10,
fontSize: '0.75rem',
fontWeight: 600,
background: badge.color + '22',
color: badge.color,
}}
className={styles.canvasHeaderVersionBadge}
style={
{
'--canvasHeaderBadgeBg': `${badge.color}22`,
'--canvasHeaderBadgeFg': badge.color,
} as React.CSSProperties
}
>
{badge.label}
</span>
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaCloudUploadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onPublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version veröffentlichen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudUploadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichen')}
</button>
</Button>
)}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaCloudDownloadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onUnpublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Veröffentlichung zurücknehmen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichung aufheben')}
</button>
</Button>
)}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaArchive}
className={styles.canvasHeaderVersionAction}
onClick={() => onArchiveVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version archivieren')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaArchive style={{ marginRight: 4 }} />
Archiv
</button>
{t('Archiv')}
</Button>
)}
{onCreateDraft && (
<button
<Button
type="button"
className={styles.retryButton}
variant={_tb}
size={_ts}
icon={FaPlus}
className={styles.canvasHeaderVersionAction}
onClick={onCreateDraft}
disabled={versionLoading}
title={t('Neuen Entwurf erstellen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
+ Entwurf
</button>
{t('+ Entwurf')}
</Button>
)}
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
{versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
</div>
)}
{executeResult && (
<div
style={{
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)',
}}
className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
>
{executeResult.success ? (
executeResult.warning ? (
<> {executeResult.warning}</>
<>{executeResult.warning}</>
) : (
<>{t('Ausführung abgeschlossen')}</>
)
) : (executeResult as { paused?: boolean }).paused ? (
) : executeResult.paused ? (
<>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
Task zu bearbeiten.
{t('Workflow pausiert. Öffne ')}
<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>
)}

View file

@ -1,7 +1,9 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* EditorChatPanel
*
* AI Chat sidebar for the GraphicalEditor.
* AI Chat sidebar for the WorkflowAutomation editor.
* Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput:
* - 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.
// 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.
useEffect(() => {
if (!workflowId) {
@ -99,7 +101,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
setHistoryLoading(true);
try {
const res = await api.get<PersistedEditorChatResponse>(
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
`/api/workflow-automation/${workflowId}/chat/messages`,
);
if (cancelled) return;
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 cleanup = startSseStream({
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`,
body,
handlers: {
onChunk: (event) => {
@ -227,7 +229,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
: m));
}
try {
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
await api.post(`/api/workflow-automation/${workflowId}/chat/stop`);
} catch {
}
abortRef.current?.();

View file

@ -1,17 +1,19 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* EditorWorkflowChatList
*
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
* as one editor chat session. Lists workflows already loaded by the parent
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
* UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow
* is treated as one editor chat session. Lists workflows already loaded by the
* parent editor (no extra fetch), supports search and "+ Neu" to start a fresh
* 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 type { Automation2Workflow } from '../../../api/workflowApi';
import type { WorkflowDefinition } from '../../../api/workflowAutomationApi';
interface EditorWorkflowChatListProps {
workflows: Automation2Workflow[];
workflows: WorkflowDefinition[];
currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => 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.
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
@ -5,16 +7,93 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../api/workflowApi';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowAutomationApi';
import type { ApiRequestFunction } from '../../../api/workflowAutomationApi';
import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
import { useWorkflowDataFlow } from '../context/WorkflowDataFlowContext';
import styles from './WorkflowFlowEditor.module.css';
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 {
node: CanvasNode | null;
@ -30,6 +109,35 @@ interface NodeConfigPanelProps {
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,
nodeType,
language,
@ -62,7 +170,12 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
const updateParam = useCallback(
(key: string, value: unknown) => {
setParams((prev) => {
const next = { ...prev, [key]: value };
const next = { ...prev };
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
@ -79,7 +192,27 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[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) ?? {};
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
@ -115,6 +248,149 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
.join('\n');
}, [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;
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>
</div>
)}
{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;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
{extractContentAccordionItems !== null ? (
<>
{extractContentContextParam ? (
<div
key={`${node.id}-${extractContentContextParam.name}`}
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 (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={params[param.name] ?? param.default}
onChange={(val) => updateParam(param.name, val)}
<div key={`${node.id}-${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={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</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>
);
};
@ -320,6 +666,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'featureInstance',
'sharepointFolder',
'sharepointFile',
'userFileFolder',
'clickupList',
'clickupTask',
'dataRef',

View file

@ -1,14 +1,16 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* NodeListItem - Draggable node type item for the sidebar.
* Used in both regular categories and I/O sub-groups.
*/
import React from 'react';
import type { NodeType } from '../../../api/workflowApi';
import type { NodeType } from '../../../api/workflowAutomationApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } 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';
interface NodeListItemProps {

View file

@ -1,15 +1,17 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* 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 { 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 { getLabel } from '../nodes/shared/utils';
import { NodeListItem } from './NodeListItem';
import styles from './Automation2FlowEditor.module.css';
import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -21,7 +23,7 @@ interface NodeSidebarProps {
language: string;
expandedCategories: Set<string>;
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>;
style?: React.CSSProperties;
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* RunTracingPanel
*
@ -7,7 +9,7 @@
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi';
import type { AutoStepLog } from '../../../api/workflowAutomationApi';
import api from '../../../api';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -98,7 +100,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
setLoading(true);
try {
const data = await request({
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
url: `/api/workflow-automation/runs/${runId}/steps`,
method: 'get',
});
setSteps(data?.steps || []);
@ -115,7 +117,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
loadSteps();
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 });
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.
*/
@ -9,8 +11,8 @@ import {
type AutoWorkflowTemplate,
type AutoTemplateScope,
type ApiRequestFunction,
} from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
} from '../../../api/workflowAutomationApi';
import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface TemplatePickerProps {
@ -50,7 +52,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
setLoading(true);
try {
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);
} catch {
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.
*/
@ -246,6 +246,7 @@
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--canvas-bg, #fafafa);
}
@ -254,47 +255,155 @@
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff);
overflow: visible;
}
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
.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 {
.canvasHeaderToolbar {
display: flex;
flex-wrap: wrap;
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;
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. */
.canvasHeaderWorkflowSelect {
flex: 0 0 auto;
width: 12.5rem;
flex: 0 1 12.5rem;
min-width: 8rem;
max-width: 100%;
padding: 0.4rem 0.5rem;
min-height: 2rem;
font-size: 0.85rem;
padding: 0.31rem 0.45rem;
min-height: 30px;
box-sizing: border-box;
font-size: 0.8125rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
border-radius: var(--button-border-radius, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderTitleBlock {
flex: 1 1 8rem;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
@ -347,26 +456,27 @@
background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto;
max-width: 100%;
margin-left: auto;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderActionPanel button {
margin-top: 0;
.canvasHeaderSplitPair :global(.button + .button) {
margin-left: 0;
}
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */
.canvasHeaderRunButton {
min-width: 12.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
.canvasHeaderRunBlocked {
background: rgba(220, 53, 69, 0.1) !important;
border: 1px solid var(--danger-color, #dc3545) !important;
color: var(--danger-color, #dc3545) !important;
cursor: help !important;
box-shadow: none !important;
}
@media (max-width: 900px) {
.canvasHeaderActionPanel {
justify-content: flex-start;
}
.canvasHeaderRunBlocked:hover:not(:disabled) {
filter: brightness(0.97);
}
.canvasHeaderRunBlocked :global(.buttonIcon) {
opacity: 0.5;
}
.canvasHeaderVersionRow {
@ -380,7 +490,7 @@
width: 100%;
}
.canvasHeaderVersionRow button {
.canvasHeaderVersionRow :global(.button) {
margin-top: 0;
}
@ -391,6 +501,57 @@
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 {
width: 11rem;
max-width: 100%;
@ -484,22 +645,183 @@
.canvasArea {
flex: 1;
padding: 2rem;
min-height: 400px;
overflow: hidden;
padding: 0;
min-height: 0;
overflow-x: visible;
overflow-y: hidden;
}
.canvasDropZone {
position: relative;
min-height: 100%;
height: 100%;
overflow: hidden;
/* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
overflow: visible;
border-radius: 8px;
/* 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-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 {
position: absolute;
left: 0;
@ -695,6 +1017,8 @@
.handleWrapper:has(.handleOutput) {
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) {
@ -726,6 +1050,16 @@
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
* 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
@ -735,17 +1069,20 @@
* a long label rather than escaping to the right.
*/
.nodeConfigPanel {
flex: 1;
min-height: 0;
padding: 1rem;
background: var(--bg-primary, #fff);
border-left: 1px solid var(--border-color, #e0e0e0);
width: 280px;
flex-shrink: 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
position: relative;
z-index: 10;
}
.nodeConfigPanel h4 {
@ -808,7 +1145,9 @@
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
(DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */
.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;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
@ -901,6 +1240,12 @@
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 */
.uploadNodeConfig {
display: flex;
@ -1491,24 +1836,6 @@
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,
.startsSelect {
padding: 0.35rem 0.5rem;
@ -1771,6 +2098,39 @@
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 */
.dynamicValueField {
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.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
* n8n-style flow builder with backend-driven node list and categories.
* 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';
@ -23,31 +25,34 @@ import {
createTemplateFromWorkflow,
copyTemplate,
importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
type Automation2Workflow,
type WorkflowGraph,
type WorkflowDefinition,
type ExecuteGraphResponse,
type WorkflowEntryPoint,
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas';
} from '../../../api/workflowAutomationApi';
import {
FlowCanvas,
type CanvasNode,
type CanvasConnection,
type CanvasStickyNote,
type FlowCanvasHandle,
type FlowCanvasViewportEditState,
} from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { WorkflowDataFlowProvider } from '../context/WorkflowDataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
@ -55,18 +60,28 @@ import { EditorWorkflowChatList } from './EditorWorkflowChatList';
import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } 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 { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], runLabel);
const LOG = '[WorkflowEditor]';
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;
mandateId?: string;
language?: string;
@ -80,7 +95,7 @@ interface Automation2FlowEditorProps {
onSourcesChanged?: () => void;
}
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instanceId,
mandateId,
language = 'de',
initialWorkflowId,
@ -92,32 +107,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSourcesChanged,
}) => {
const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [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 [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
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 [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
@ -129,20 +156,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
instanceId,
mandateId: mandateId || '',
featureInstanceId: instanceId,
surface: 'graphEditor',
surface: 'workflowAutomation',
}), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const didBootstrapEmptyCanvasRef = useRef(false);
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(() => {
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';
}, [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(
() =>
@ -219,26 +251,77 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [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(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
setInvocations(inv);
if (!graph?.nodes?.length) {
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
return;
(
graph: WorkflowGraph | null | undefined,
wfInvocations: WorkflowEntryPoint[] | undefined,
opts?: { skipHistory?: boolean }
) => {
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
pushCanvasHistoryPastFromCurrent();
}
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
setInvocations(wfInvocations ?? []);
const g: WorkflowGraph = graph ?? { nodes: [], connections: [] };
const { nodes, connections } = fromApiGraph(g, nodeTypes);
setCanvasNodes(nodes);
setCanvasConnections(connections);
},
[nodeTypes, language, t]
[nodeTypes, pushCanvasHistoryPastFromCurrent]
);
const handleFromApiGraph = useCallback(
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
(graph: WorkflowGraph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations);
},
[applyGraphWithSync]
@ -263,11 +346,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
});
return;
}
if (missingStartNodeBlocking) {
setExecuteResult({
success: false,
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
});
return;
}
setExecuting(true);
setExecuteResult(null);
try {
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 } : {}),
});
setExecuteResult(result);
@ -280,7 +370,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
@ -296,19 +386,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
0,
);
const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => ({
success: true,
warning:
errorCount > 0
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
: undefined,
});
const _buildSaveResult = (): ExecuteGraphResponse => {
const parts: string[] = [];
if (errorCount > 0) {
parts.push(
t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
);
}
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);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
const updated = await updateWorkflow(request, currentWorkflowId, {
graph,
invocations,
targetFeatureInstanceId,
});
setInvocations(updated.invocations ?? []);
setExecuteResult(_buildSaveResult());
} else {
const label = await promptInput(t('Workflow-Name:'), {
@ -320,14 +423,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setSaving(false);
return;
}
const created = await createWorkflow(request, instanceId, {
const created = await createWorkflow(request, {
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
targetFeatureInstanceId,
mandateId,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setInvocations(created.invocations ?? []);
setWorkflows((prev) => [...prev, created]);
setExecuteResult(_buildSaveResult());
}
@ -336,12 +440,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
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(
async (workflowId: string) => {
try {
const wf = await fetchWorkflow(request, instanceId, workflowId);
const wf = await fetchWorkflow(request, workflowId);
if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations);
} else {
@ -361,9 +465,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, []);
try {
const result = await fetchWorkflows(request, instanceId);
const result = await fetchWorkflows(request);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (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(
@ -385,7 +489,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (workflowId) handleLoad(workflowId);
else {
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, []);
}
},
[handleLoad, applyGraphWithSync, t]
@ -394,36 +498,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleNew = useCallback(() => {
setCurrentWorkflowId(null);
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
applyGraphWithSync({ nodes: [], connections: [] }, []);
}, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
setCanvasNodes((prev) => {
const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n;
const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
const newCount = switchOutputCountFromCases(parameters.cases);
next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
}
return next;
})
);
});
return nextNodes;
});
}, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
setCanvasNodes((prev) => {
const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
const newCount = switchOutputCountFromCases(merged.cases);
next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
}
return next;
})
);
});
return nextNodes;
});
}, []);
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 () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = await fetchNodeTypes(request, instanceId, language);
const data = await fetchNodeTypes(request, mandateId || '', language);
setNodeTypes(data.nodeTypes);
setCategories(data.categories);
if (data.portTypeCatalog) {
@ -461,6 +560,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
}
if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]);
@ -468,17 +568,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setLoading(false);
}
}, [instanceId, language, request]);
}, [language, request]);
const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try {
const result = await fetchWorkflows(request, instanceId);
const result = await fetchWorkflows(request, { mandateId: mandateId || undefined });
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (e) {
console.error(`${LOG} loadWorkflows failed`, e);
}
}, [instanceId, request]);
}, [request, mandateId]);
useEffect(() => {
loadNodeTypes();
@ -488,6 +587,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
loadWorkflows();
}, [loadWorkflows]);
useEffect(() => {
setCanvasStickyNotes([]);
}, [currentWorkflowId]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
@ -498,17 +601,34 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
useEffect(() => {
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
if (currentWorkflowId || initialWorkflowId) {
didBootstrapEmptyCanvasRef.current = false;
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,
nodeTypes.length,
currentWorkflowId,
initialWorkflowId,
canvasNodes.length,
canvasConnections.length,
invocations.length,
applyGraphWithSync,
t,
]);
const toggleCategory = useCallback((id: string) => {
@ -522,7 +642,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@ -548,17 +667,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
);
const loadVersions = useCallback(async () => {
if (!instanceId || !currentWorkflowId) {
if (!currentWorkflowId) {
setVersions([]);
return;
}
try {
const v = await fetchVersions(request, instanceId, currentWorkflowId);
const v = await fetchVersions(request, currentWorkflowId);
setVersions(v);
} catch (e) {
console.error(`${LOG} loadVersions failed`, e);
}
}, [instanceId, currentWorkflowId, request]);
}, [currentWorkflowId, request]);
useEffect(() => {
loadVersions();
@ -579,10 +698,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handlePublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await publishVersion(request, instanceId, versionId);
await publishVersion(request, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -590,15 +708,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
[request, loadVersions]
);
const handleUnpublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await unpublishVersion(request, instanceId, versionId);
await unpublishVersion(request, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -606,15 +723,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
[request, loadVersions]
);
const handleArchiveVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await archiveVersion(request, instanceId, versionId);
await archiveVersion(request, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -622,14 +738,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
[request, loadVersions]
);
const handleCreateDraft = useCallback(async () => {
if (!instanceId || !currentWorkflowId) return;
if (!currentWorkflowId) return;
setVersionLoading(true);
try {
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
const draft = await createDraftVersion(request, currentWorkflowId);
await loadVersions();
setCurrentVersionId(draft.id);
} catch (e: unknown) {
@ -637,16 +753,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setVersionLoading(false);
}
}, [request, instanceId, currentWorkflowId, loadVersions]);
}, [request, currentWorkflowId, loadVersions]);
// Template: save current workflow as template
const [templateSaving, setTemplateSaving] = useState(false);
const handleSaveAsTemplate = useCallback(
async (scope: AutoTemplateScope) => {
if (!instanceId || !currentWorkflowId) return;
if (!currentWorkflowId) return;
setTemplateSaving(true);
try {
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
await createTemplateFromWorkflow(request, currentWorkflowId, scope);
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -654,16 +770,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setTemplateSaving(false);
}
},
[request, instanceId, currentWorkflowId]
[request, currentWorkflowId]
);
// Template: new workflow from template
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const handleNewFromTemplate = useCallback(
async (templateId: string) => {
if (!instanceId) return;
try {
const wf = await copyTemplate(request, instanceId, templateId);
const wf = await copyTemplate(request, templateId);
setWorkflows((prev) => [...prev, wf]);
setCurrentWorkflowId(wf.id);
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) });
}
},
[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]);
@ -741,7 +834,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle}
/>
);
@ -749,15 +841,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected =
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 (
<div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div
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}>
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button
@ -807,12 +945,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
activeTab={udbTab as UdbTab}
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
hideTabs={['chats']}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}
onWorkflowImportedFromFile={async (workflowId) => {
await loadWorkflows();
handleWorkflowSelect(workflowId);
onFileSelect={async (fileId, fileName) => {
if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) {
try {
const result = await importWorkflowFromFile(request, { fileId });
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>
@ -829,15 +975,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
workspacePanelOpen={leftPanelOpen}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeBlockedReason={
hasGraphErrors
? 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={() => {
if (firstErrorNodeId) {
@ -857,17 +1005,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
canvasEdit={canvasHeaderEdit}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<FlowCanvas
ref={flowCanvasRef}
nodes={canvasNodes}
connections={canvasConnections}
nodeTypes={nodeTypes}
@ -879,13 +1024,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors}
onViewportEditState={setCanvasViewportEdit}
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
onConnectionToolActiveChange={setCanvasConnectionToolActive}
stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes}
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 fileId = p?.files?.[0]?.id;
if (!fileId) return false;
try {
const result = await importWorkflowFromFile(request, instanceId, { fileId });
const result = await importWorkflowFromFile(request, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true;
@ -897,7 +1047,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
/>
</div>
{configurableSelected && selectedNode && (
<Automation2DataFlowProvider
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
<WorkflowDataFlowProvider
node={selectedNode}
nodes={canvasNodes}
connections={canvasConnections}
@ -907,6 +1058,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes}
conditionOperatorCatalog={conditionOperatorCatalog}
instanceId={instanceId}
request={request}
>
@ -921,14 +1073,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
request={request}
verboseSchema={verboseSchema}
/>
</Automation2DataFlowProvider>
</WorkflowDataFlowProvider>
</div>
)}
</div>
</div>
{/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
<div
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}>
<button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
@ -961,12 +1117,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker
open={templatePickerOpen}
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 { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } 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, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader';
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
export * from './nodes/shared/utils';
export * from './nodes/shared/constants';
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
*/
@ -6,14 +8,20 @@ import React from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import styles from '../../editor/WorkflowFlowEditor.module.css';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const ctx = useWorkflowDataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: 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>
<div className={styles.formFieldInputs}>
<input
placeholder={t('name')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder={t('label')}
placeholder={t('Bezeichnung')}
value={f.label ?? ''}
onChange={(e) => {
const label = e.target.value;
const next = [...fields];
next[i] = { ...next[i], label: e.target.value };
next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
updateParam('fields', next);
}}
/>
@ -88,7 +88,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
value={f.type ?? 'text'}
onChange={(e) => {
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);
}}
style={{ width: 'auto', minWidth: 90 }}
@ -118,12 +123,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
<FaTimes />
</button>
</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>
))}
<button
type="button"
onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
updateParam('fields', [
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
type: 'text',
label: '',
required: false,
},
])
}
>
+ {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 { 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.
*
@ -11,7 +13,7 @@
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
@ -52,7 +54,7 @@ const REMOVE_BTN: React.CSSProperties = {
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const dataFlow = useWorkflowDataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
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
* hierarchical DataPicker.
@ -10,14 +12,14 @@
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const dataFlow = useWorkflowDataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
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".
*
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
* 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:
* - 0 results -> hint to create a feature instance for this mandate
@ -42,7 +44,7 @@ export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
setLoading(true);
setLoadError(null);
request({
url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
url: `/api/workflow-automation/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
method: 'get',
})
.then((res: unknown) => {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
@ -5,11 +7,11 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
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;
@ -60,7 +62,7 @@ function _parseTokensInTemplate(
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const dataFlow = useWorkflowDataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null);
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.
* Maps frontendType strings to React components.
*/
import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import type { NodeTypeParameter } from '../../../../api/workflowAutomationApi';
import type { ApiRequestFunction } from '../../../../api/workflowAutomationApi';
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 {
param: NodeTypeParameter;
@ -17,6 +25,10 @@ export interface FieldRendererProps {
instanceId?: string;
request?: ApiRequestFunction;
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>;
@ -26,14 +38,26 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
// ---------------------------------------------------------------------------
import React from 'react';
import { SchedulePlanner } from '../../../SchedulePlanner';
import {
buildCronFromSpec,
scheduleSpecFromParams,
scheduleSpecToPersistentJson,
type ScheduleSpec,
} from '../../../../utils/scheduleCron';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils';
import { postUpstreamPaths } from '../../../../api/workflowApi';
import { postUpstreamPaths } from '../../../../api/workflowAutomationApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
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 { getApiBaseUrl } from '../../../../../config/config';
@ -98,29 +122,145 @@ const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) =>
</div>
);
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] =
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
/** Backend may send `options: ["a","b"]` or `options: [{ value, label }, ...]` (e.g. context.extractContent). */
function _normalizedSelectOptions(raw: unknown): Array<{ value: string; label: 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 (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
{showNameLine ? (
<div
id={titleId}
style={{
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) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
{options.map((opt) => {
const selected = current === opt.value;
return (
<button
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>
);
};
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] =
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
const options = _normalizedSelectOptions(
param.frontendOptions?.options ?? param.options ?? []
);
const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) => {
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>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{options.map((opt) => (
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} />
{opt}
<label key={opt.value} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt.label}
</label>
))}
</div>
@ -162,7 +302,7 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const dataFlow = useWorkflowDataFlow();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
const [loadError, setLoadError] = React.useState<string | null>(null);
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;
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
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) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
@ -190,7 +330,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
return;
}
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
postUpstreamPaths(request, graph, dataFlow.currentNodeId)
.then(({ paths }) => {
const opts = paths
.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 { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const ctx = useWorkflowDataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
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 updateField = (idx: number, field: string, val: unknown) => {
const next = [...fields];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
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 = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff',
@ -565,7 +696,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
type="text"
placeholder={t('Bezeichnung (Anzeigename)')}
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 }}
/>
<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 }}
>×</button>
</div>
{/* Row 2: Name + Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 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>
{/* Row 2: Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
<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) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
@ -601,6 +722,14 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
Pflicht
</label>
</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' && (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<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' }}>
<input
type="text"
placeholder={t('Name')}
value={String(sub.name ?? '')}
placeholder={t('Bezeichnung')}
value={String(sub.label ?? sub.name ?? '')}
onChange={(e) => {
const label = e.target.value;
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);
}}
style={{ ...inputStyle, flex: 1 }}
@ -621,8 +755,16 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<select
value={String(sub.type ?? 'text')}
onChange={(e) => {
const typeId = e.target.value;
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);
}}
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 }}
>×</button>
</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>
))}
<button
type="button"
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);
}}
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 { t } = useLanguage();
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, onPatchParams }) => {
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 (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={t('0 9 * * *')}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
/>
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
<SchedulePlanner value={spec} onChange={handlePlanner} />
</div>
);
};
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val });
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
<option value="contains">{t('enthält')}</option>
<option value="empty">{t('ist leer')}</option>
<option value="not_empty">{t('ist nicht leer')}</option>
<option value="is_true">{t('ist wahr')}</option>
<option value="is_false">{t('ist falsch')}</option>
</select>
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);
};
const ConditionBuilder = ConditionEditor;
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
@ -913,11 +1065,13 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
hidden: HiddenInput,
dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer,
contextAssignments: ContextAssignmentsEditor,
userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker,
clickupList: FolderPicker,
userFileFolder: UserFileFolderPicker,
clickupList: ClickUpListPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,
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