Compare commits
110 commits
feat/grafi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f47dd395c | |||
| 5109279ebd | |||
| 6520763736 | |||
| 7eb305f910 | |||
| a13a158c67 | |||
| cd14babb2e | |||
| d398907edc | |||
| 49c3cf7290 | |||
| 19a39bc443 | |||
| 7a914ce2d9 | |||
| b21fa78665 | |||
| 78457a7d27 | |||
| 31ed78e863 | |||
|
|
059bbe956a | ||
|
|
b36b303def | ||
| 30db1b8316 | |||
| 4475a45a26 | |||
| 5ce871fb3c | |||
| 59b1e1f6a7 | |||
| 991952dde9 | |||
| 7876a528f5 | |||
| ff68307a39 | |||
| beeed79aaa | |||
| 2031c87529 | |||
| e94420dfe9 | |||
| aa982680fa | |||
| e727996a18 | |||
| 5711450606 | |||
| 76f35a7f22 | |||
| 5aacf17b13 | |||
| ece5f17e2a | |||
| 8e67efa092 | |||
| 9d081e8819 | |||
| 1c539076e5 | |||
| 7da7ad5041 | |||
| 6c319a4170 | |||
| bf4916b447 | |||
| ab5ead3416 | |||
| 275b5125c1 | |||
| 57319507bb | |||
| f27bfd2221 | |||
| 8f9d233d8c | |||
| 5a5d24bbe2 | |||
| 639cac2e33 | |||
| 0331a59da3 | |||
| 3cc2f4decf | |||
| 12868fdd17 | |||
| 50c05e91d7 | |||
| 036e6a38db | |||
| 8d24d57719 | |||
| 554d798ae2 | |||
| 9047304934 | |||
| 7a228f0181 | |||
| a7921d409e | |||
| 077dbca759 | |||
| 6dbf91afb2 | |||
| f35e22c7f4 | |||
| 234ffa7896 | |||
| 86a3ac647c | |||
| 0f1f9781b7 | |||
| 1a4f18392c | |||
| 9d18e743bc | |||
| 638f18cd55 | |||
| f1234fedb3 | |||
|
|
4f2745cc2e | ||
|
|
1308e6d415 | ||
|
|
65170d9e4c | ||
|
|
f37774ff36 | ||
|
|
ca6261fb1a | ||
|
|
bb441f5268 | ||
|
|
e1260c173c | ||
|
|
12e10350d9 | ||
|
|
de8007644f | ||
|
|
abdb499067 | ||
|
|
629333f910 | ||
|
|
8c0e2ee8af | ||
|
|
0e89ed2a64 | ||
|
|
ba5b0fa8e8 | ||
|
|
230055a4fb | ||
|
|
0d8e6501d3 | ||
|
|
2ee08c314b | ||
|
|
ccb2798170 | ||
|
|
a6b37ed684 | ||
|
|
791d575b7d | ||
|
|
544f36460a | ||
|
|
5c55312c60 | ||
|
|
9b6edec74e | ||
|
|
956a226b1b | ||
| 4356394fd8 | |||
| 02e7701329 | |||
| 1c6f1ac435 | |||
| 98a14a5394 | |||
| bcc632927d | |||
| c5b27c0fbd | |||
| a9bdb2d4d4 | |||
| 893326e51d | |||
|
|
9e872910f4 | ||
|
|
3997c6ec63 | ||
|
|
7c35c7117b | ||
|
|
13af1dbb05 | ||
|
|
bbd78696e6 | ||
|
|
0178de9650 | ||
|
|
a6241fb296 | ||
|
|
51cad2cab6 | ||
|
|
ccb6da36f0 | ||
|
|
e1d06e2a9d | ||
|
|
2ade186821 | ||
|
|
708687a5e4 | ||
|
|
197bc51632 | ||
|
|
d3c3a5d465 |
526 changed files with 20001 additions and 31520 deletions
|
|
@ -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
|
||||
53
.forgejo/workflows/int_porta-int-ui-nyla.yml
Normal file
53
.forgejo/workflows/int_porta-int-ui-nyla.yml
Normal 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
|
||||
53
.forgejo/workflows/main_porta-main-ui-nyla.yml
Normal file
53
.forgejo/workflows/main_porta-main-ui-nyla.yml
Normal 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
|
||||
71
.github/workflows/poweron_nyla_int.yml
vendored
71
.github/workflows/poweron_nyla_int.yml
vendored
|
|
@ -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
|
||||
71
.github/workflows/poweron_nyla_main.yml
vendored
71
.github/workflows/poweron_nyla_main.yml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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
|
||||
190
config/config.ts
190
config/config.ts
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
2
env.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
|
|
|
|||
|
|
@ -185,7 +185,10 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company & legal details · 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;">© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
27
src/App.tsx
27
src/App.tsx
|
|
@ -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 />} />
|
||||
|
|
|
|||
64
src/api.ts
64
src/api.ts
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import type { AttributeType } from '../utils/attributeTypeMapper';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
135
src/api/clickupApi.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Neutralization API
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import api from '../api';
|
||||
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Redmine API
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Store API
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import api from '../api';
|
||||
|
||||
export interface TableListViewRow {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessLevelSelect
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessRulesEditor
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessRulesTable
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessRules Components
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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!]}
|
||||
{state.connector && CONNECTOR_ICONS[state.connector]}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* ChatInput -- Shared chat input component.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* ChatMessageList -- Shared chat message display component.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ApplicationRendererProps {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface HtmlRendererProps {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ImageRendererProps {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { JsonRenderer } from './JsonRenderer';
|
||||
export { ImageRenderer } from './ImageRenderer';
|
||||
export { TextRenderer } from './TextRenderer';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
106
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal file
106
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue