Compare commits

...

47 commits

Author SHA1 Message Date
639cac2e33 fixes udb
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 2s
2026-05-27 16:48:52 +02:00
0331a59da3 config fix
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 17:35:52 +02:00
3cc2f4decf db fixed import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 16:30:35 +02:00
12868fdd17 Pydantic FK als Single Source of Truth
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 15:14:09 +02:00
50c05e91d7 db backup-restore with fk
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 14:33:50 +02:00
036e6a38db fixed db stream upload
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 43s
2026-05-24 14:59:05 +02:00
8d24d57719 fixed db download
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
2026-05-24 08:59:46 +02:00
554d798ae2 dbsync
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-24 08:15:06 +02:00
9047304934 fix: use printf for SSH key to preserve trailing newline
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:22:40 +02:00
7a228f0181 fix: rewrite workflows for Infomaniak SSH deploy, fix API URLs
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:03:47 +02:00
a7921d409e fix: use full GitHub URLs for Azure actions (not mirrored on Forgejo)
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:42:29 +02:00
077dbca759 fix: add env file cleanup step to ui-nyla workflows
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:40:42 +02:00
6dbf91afb2 refactor: migrate to Forgejo workflows, normalize env file names, remove GitHub Actions
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:34:21 +02:00
f35e22c7f4 Sync: full codebase from GitHub frontend_nyla main
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 23:54:30 +02:00
Ida
234ffa7896 updated deployment file and env file
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m33s
2026-05-22 10:24:04 +02:00
Ida
86a3ac647c fix:wrong base url
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-22 07:53:36 +02:00
Ida
0f1f9781b7 fix: wrong backend on porta instance
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Has been cancelled
2026-05-22 07:52:06 +02:00
Ida
1a4f18392c sudo berechtigung zur rsync installation wieder entfernt
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-21 08:55:29 +02:00
Ida
9d18e743bc sudo berechtigung zur rsync installation
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m41s
2026-05-21 08:47:08 +02:00
Ida
638f18cd55 rsync vor deployment installieren
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m43s
2026-05-21 08:38:02 +02:00
Ida
f1234fedb3 updated deployment workflow to forgejo
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 6m11s
2026-05-21 08:29:02 +02:00
Patrick Motsch
4f2745cc2e
Merge pull request #86 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 2s
Int
2026-05-19 22:35:29 +02:00
Patrick Motsch
ca6261fb1a
Merge pull request #85 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 3s
Int
2026-05-17 00:10:06 +02:00
Patrick Motsch
9b6edec74e
Merge pull request #80 from valueonag/int
Int
2026-05-10 22:24:04 +02:00
idittrich-valueon
956a226b1b
Merge pull request #75 from valueonag/int
Int
2026-05-04 14:09:37 +02:00
Ida
4356394fd8 forgejo setup 2026-04-21 10:35:36 +02:00
Ida
02e7701329 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
1c6f1ac435 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
98a14a5394 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
bcc632927d trigger deploy 2026-04-21 10:35:36 +02:00
Ida
c5b27c0fbd trigger deploy 2026-04-21 10:35:36 +02:00
Ida
a9bdb2d4d4 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
893326e51d add forgejo deploy workflow 2026-04-21 10:35:36 +02:00
Patrick Motsch
9e872910f4
Merge pull request #52 from valueonag/int
Int
2026-04-21 07:48:36 +02:00
Patrick Motsch
3997c6ec63
Merge pull request #50 from valueonag/int
Int
2026-04-21 00:57:41 +02:00
Patrick Motsch
7c35c7117b
Merge pull request #47 from valueonag/int
Int
2026-04-20 19:11:14 +02:00
Patrick Motsch
13af1dbb05
Merge pull request #45 from valueonag/int
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Has been cancelled
Int
2026-04-19 01:39:32 +02:00
Patrick Motsch
bbd78696e6
Merge pull request #42 from valueonag/int
Int
2026-04-17 21:54:34 +02:00
Patrick Motsch
0178de9650
Merge pull request #34 from valueonag/int
Some checks are pending
Deploy Nyla Frontend to Production / build-and-deploy (push) Waiting to run
Int
2026-04-14 16:28:18 +02:00
Patrick Motsch
a6241fb296
Merge pull request #32 from valueonag/int
Int
2026-04-14 13:32:25 +02:00
Patrick Motsch
51cad2cab6
Merge pull request #28 from valueonag/int
core class for system attributes sysCreated / sysModified
2026-04-04 19:15:21 +02:00
Patrick Motsch
ccb6da36f0
Merge pull request #27 from valueonag/int
Int
2026-04-04 16:21:11 +02:00
Patrick Motsch
e1d06e2a9d
Merge pull request #24 from valueonag/int
Int
2026-03-23 10:39:04 +01:00
Patrick Motsch
2ade186821
Merge pull request #22 from valueonag/int
fixed rendering issues
2026-03-22 11:11:45 +01:00
Patrick Motsch
708687a5e4
Merge pull request #21 from valueonag/int
Int
2026-03-22 01:28:21 +01:00
Patrick Motsch
197bc51632
Merge pull request #20 from valueonag/int
Int
2026-03-19 13:44:20 +01:00
Patrick Motsch
d3c3a5d465
Merge pull request #15 from valueonag/int
fix feature instance passing
2026-03-13 08:31:28 +01:00
95 changed files with 8438 additions and 6317 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,71 +0,0 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1 @@
// Export simple configuration service export { getApiBaseUrl, getAppName } from './config';
export * from './config';
// Re-export commonly used functions
export {
getApiBaseUrl,
getAppName,
isDevelopment,
isProduction,
isDebugMode,
config
} from './config';

View file

@ -174,8 +174,8 @@ function App() {
{/* Workspace + Automation2 Editor */} {/* Workspace + Automation2 Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} /> <Route path="editor" element={<FeatureViewPage view="editor" />} />
{/* Automation2 Workflows & Tasks */} {/* Automation2: legacy workflows URL → editor */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} /> <Route path="workflows" element={<Navigate to="../editor" replace />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} /> <Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */} {/* Teams Bot Feature Views */}

View file

@ -341,7 +341,7 @@ export interface DataSourceSettings {
export interface CostEstimate { export interface CostEstimate {
estimatedTokens: number; estimatedTokens: number;
estimatedUsd: number; estimatedChf: number;
basis: { basis: {
kind: string; kind: string;
limits: Record<string, number>; limits: Record<string, number>;
@ -373,24 +373,9 @@ export async function getDataSourceCostEstimate(
}); });
} }
export interface PatchFlagResponse { // Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
sourceId: string; // generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
resetDescendantIds: string[]; // `UdbSourcesProvider` and the wiki UDB reference page.
updatedAncestors: { id: string; [key: string]: any }[];
[key: string]: any;
}
export async function patchDataSourceRagIndex(
request: ApiRequestFunction,
dataSourceId: string,
ragIndexEnabled: boolean | null
): Promise<PatchFlagResponse> {
return await request({
url: `/api/datasources/${dataSourceId}/rag-index`,
method: 'patch',
data: { ragIndexEnabled }
});
}
// ============================================================================ // ============================================================================
// RAG INVENTORY // RAG INVENTORY

View file

@ -32,6 +32,10 @@ export interface PortField {
enumValues?: string[] | null; enumValues?: string[] | null;
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */ /** When true, surface at the top of the DataPicker as the most common/recommended pick. */
recommended?: boolean; recommended?: boolean;
/** Human label from portTypeCatalog (backend). Preferred over technical path in DataPicker. */
pickerLabel?: string | null;
/** Backend: segment for one list element (between List field and nested field). */
pickerItemLabel?: string | null;
} }
export interface PortSchema { export interface PortSchema {
@ -39,6 +43,20 @@ export interface PortSchema {
fields: PortField[]; fields: PortField[];
} }
/** One pickable binding — defined on ``outputPorts[n].dataPickOptions`` (authoritative list from gateway). */
export interface DataPickOption {
path: (string | number)[];
pickerLabel: string;
detail?: string;
recommended?: boolean;
iterable?: boolean;
/** For display and optional strict compatibility (e.g. str, Any). */
type?: string;
}
/** @deprecated Prefer ``outputPorts[].dataPickOptions``; kept for older payloads. */
export type OutputPickHint = DataPickOption;
export interface InputPortDef { export interface InputPortDef {
accepts: string[]; accepts: string[];
} }
@ -53,6 +71,11 @@ export interface OutputPortDef {
schema: string | GraphDefinedSchemaRef; schema: string | GraphDefinedSchemaRef;
dynamic?: boolean; dynamic?: boolean;
deriveFrom?: string; deriveFrom?: string;
/**
* When set, DataPicker uses **only** this list for that port (no portTypeCatalog expansion).
* Authoritative, like `parameters` for node configuration.
*/
dataPickOptions?: DataPickOption[];
} }
export interface NodeType { export interface NodeType {
@ -76,7 +99,6 @@ export interface NodeType {
action?: string; action?: string;
}; };
} }
export interface NodeTypeCategory { export interface NodeTypeCategory {
id: string; id: string;
label: Record<string, string> | string; label: Record<string, string> | string;
@ -94,10 +116,19 @@ export interface FormFieldType {
portType: string; portType: string;
} }
export interface ConditionOperatorDef {
id: string;
label: string;
labelKey?: string;
needsValue: boolean;
valueInput?: { kind: string; options?: string[] };
}
export interface NodeTypesResponse { export interface NodeTypesResponse {
nodeTypes: NodeType[]; nodeTypes: NodeType[];
categories: NodeTypeCategory[]; categories: NodeTypeCategory[];
portTypeCatalog?: Record<string, PortSchema>; portTypeCatalog?: Record<string, PortSchema>;
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
systemVariables?: Record<string, SystemVariable>; systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[]; formFieldTypes?: FormFieldType[];
} }
@ -288,15 +319,17 @@ export async function fetchNodeTypes(
const nodeTypes = data?.nodeTypes ?? []; const nodeTypes = data?.nodeTypes ?? [];
const categories = data?.categories ?? []; const categories = data?.categories ?? [];
const portTypeCatalog = data?.portTypeCatalog ?? undefined; const portTypeCatalog = data?.portTypeCatalog ?? undefined;
const conditionOperatorCatalog = data?.conditionOperatorCatalog ?? undefined;
const systemVariables = data?.systemVariables ?? undefined; const systemVariables = data?.systemVariables ?? undefined;
const formFieldTypes = data?.formFieldTypes ?? undefined; const formFieldTypes = data?.formFieldTypes ?? undefined;
console.log( console.log(
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` + `${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` + `${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
`${conditionOperatorCatalog ? Object.keys(conditionOperatorCatalog).length : 0} conditionKinds, ` +
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` + `${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` +
`${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes` `${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes`
); );
return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes }; return { nodeTypes, categories, portTypeCatalog, conditionOperatorCatalog, systemVariables, formFieldTypes };
} }
export interface UpstreamPathEntry { export interface UpstreamPathEntry {
@ -306,6 +339,39 @@ export interface UpstreamPathEntry {
type: string; type: string;
label: string; label: string;
scopeOrigin: 'data' | 'loop' | 'system'; scopeOrigin: 'data' | 'loop' | 'system';
valueKind?: string;
}
export interface ConditionMetaResponse {
valueKind: string;
operators: ConditionOperatorDef[];
}
export interface ConditionMetaRequest {
graph: Automation2Graph;
nodeId?: string;
ref: { type: 'ref'; nodeId: string; path: (string | number)[] };
}
/**
* POST /api/workflows/{instanceId}/condition-meta operators for a DataRef (If/Else).
*/
export async function fetchConditionMeta(
request: ApiRequestFunction,
instanceId: string,
body: ConditionMetaRequest,
language = 'de'
): Promise<ConditionMetaResponse> {
const data = await request({
url: `/api/workflows/${instanceId}/condition-meta`,
method: 'post',
params: { language },
data: body,
});
return {
valueKind: String(data?.valueKind ?? 'unknown'),
operators: (data?.operators ?? []) as ConditionOperatorDef[],
};
} }
/** /**
@ -325,6 +391,41 @@ export async function postUpstreamPaths(
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
} }
/** Scope-aware data sources for the DataPicker — all loop-scope logic lives on the backend. */
export interface GraphDataSources {
/** Ancestor node IDs that are valid sources (loop body nodes excluded when on Done branch). */
availableSourceIds: string[];
/** Maps nodeId → output port index to use instead of 0 (e.g. loop node on Done branch → 1). */
portIndexOverrides: Record<string, number>;
/** IDs of flow.loop nodes whose body the current node is inside (show currentItem etc.). */
loopBodyContextIds: string[];
}
/**
* POST /api/workflows/{instanceId}/graph-data-sources
*
* Returns scope-aware source list so the DataPicker needs zero graph-traversal logic.
* The graph connections must use { source, target, sourceOutput?, targetInput? } format.
*/
export async function fetchGraphDataSources(
request: ApiRequestFunction,
instanceId: string,
nodeId: string,
nodes: Array<{ id: string; type?: string }>,
connections: Array<{ source: string; target: string; sourceOutput?: number; targetInput?: number }>,
): Promise<GraphDataSources> {
const data = await request({
url: `/api/workflows/${instanceId}/graph-data-sources`,
method: 'post',
data: { nodeId, graph: { nodes, connections } },
});
return {
availableSourceIds: data?.availableSourceIds ?? [],
portIndexOverrides: data?.portIndexOverrides ?? {},
loopBodyContextIds: data?.loopBodyContextIds ?? [],
};
}
/** GET saved workflow graph variant of upstream-paths (requires workflowId). */ /** GET saved workflow graph variant of upstream-paths (requires workflowId). */
export async function getUpstreamPathsSaved( export async function getUpstreamPathsSaved(
request: ApiRequestFunction, request: ApiRequestFunction,
@ -670,6 +771,23 @@ export async function completeTask(
}); });
} }
/** Cancel a pending human task and stop its workflow run (Graphical Editor). */
export async function cancelPendingTaskStopRun(
request: ApiRequestFunction,
instanceId: string,
taskId: string
): Promise<{ success: boolean; runId?: string | null; taskId: string }> {
const data = await request({
url: `/api/workflows/${instanceId}/tasks/${taskId}/cancel`,
method: 'post',
});
return {
success: Boolean(data?.success),
runId: data?.runId,
taskId: data?.taskId ?? taskId,
};
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Versions (AutoVersion Lifecycle) // Versions (AutoVersion Lifecycle)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View file

@ -11,6 +11,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal'; import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './AddConnectionWizard.module.css'; import styles from './AddConnectionWizard.module.css';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -74,6 +75,8 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
onMsftAdminConsent, onMsftAdminConsent,
isConnecting = false, isConnecting = false,
}) => { }) => {
const { t } = useLanguage();
const [state, setState] = useState<WizardState>({ const [state, setState] = useState<WizardState>({
currentStep: 'connector', currentStep: 'connector',
connector: null, connector: null,
@ -125,7 +128,7 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
}; };
return ( 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 */} {/* Stepper */}
<div className={styles.stepper}> <div className={styles.stepper}>
{steps.map((s, i) => ( {steps.map((s, i) => (
@ -146,8 +149,8 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Connector ---- */} {/* ---- Step: Connector ---- */}
{state.currentStep === 'connector' && ( {state.currentStep === 'connector' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3> <h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p> <p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
<div className={styles.connectorGrid}> <div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => ( {(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button <button
@ -167,25 +170,23 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Consent ---- */} {/* ---- Step: Consent ---- */}
{state.currentStep === 'consent' && ( {state.currentStep === 'consent' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3> <h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche {t('Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus {provider} zurückgreifen kann?', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : t('diesem Dienst') })}
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
</p> </p>
<p className={styles.stepHint}> <p className={styles.stepHint}>
Du kannst dies später jederzeit in der UDB pro Datenquelle steuern. {t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
</p> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}> <button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> Ja, aktivieren <FaCheck /> {t('Ja, aktivieren')}
</button> </button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}> <button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
Nein, überspringen {t('Nein, überspringen')}
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div> </div>
</div> </div>
)} )}
@ -196,13 +197,12 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
<div style={{ textAlign: 'center', marginBottom: 16 }}> <div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} /> <FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div> </div>
<h3 className={styles.stepTitle}>Organisations-Zustimmung (optional)</h3> <h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. {t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
So müssen andere Benutzer nicht einzeln bestätigen.
</p> </p>
<p className={styles.stepHint}> <p className={styles.stepHint}>
Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt. {t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
</p> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button <button
@ -210,14 +210,14 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
className={styles.consentButtonYes} className={styles.consentButtonYes}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }} onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
> >
<FaShieldAlt /> Admin-Zustimmung erteilen <FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
</button> </button>
<button type="button" className={styles.consentButtonNo} onClick={goNext}> <button type="button" className={styles.consentButtonNo} onClick={goNext}>
Überspringen {t('Überspringen')}
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div> </div>
</div> </div>
)} )}
@ -225,9 +225,9 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Infomaniak PAT ---- */} {/* ---- Step: Infomaniak PAT ---- */}
{state.currentStep === 'infomaniakPat' && ( {state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3> <h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein. {t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
</p> </p>
<input <input
type="password" type="password"
@ -238,14 +238,14 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
autoFocus autoFocus
/> />
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleFinalConnect} onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()} disabled={isConnecting || !state.infomaniakToken.trim()}
> >
{isConnecting ? 'Verbinden…' : 'Verbinden'} {isConnecting ? t('Verbinden…') : t('Verbinden')}
{!isConnecting && <FaArrowRight size={12} />} {!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>
@ -255,31 +255,31 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Connect ---- */} {/* ---- Step: Connect ---- */}
{state.currentStep === 'connect' && ( {state.currentStep === 'connect' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Verbindung herstellen</h3> <h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
<div className={styles.summary}> <div className={styles.summary}>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span> <span className={styles.summaryKey}>{t('Anbieter')}</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp; {state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'} {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span> </span>
</div> </div>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Wissensdatenbank</span> <span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{state.knowledgeEnabled ? 'Aktiv' : 'Nicht aktiv'} {state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
</span> </span>
</div> </div>
</div> </div>
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleFinalConnect} onClick={handleFinalConnect}
disabled={isConnecting} disabled={isConnecting}
> >
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`} {isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })}
{!isConnecting && <FaArrowRight size={12} />} {!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>

View file

@ -6,7 +6,7 @@
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas'; import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph'; import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
export interface Automation2DataFlowContextValue { export interface Automation2DataFlowContextValue {
currentNodeId: string; currentNodeId: string;
@ -19,6 +19,8 @@ export interface Automation2DataFlowContextValue {
systemVariables: Record<string, SystemVariable>; systemVariables: Record<string, SystemVariable>;
/** Canonical form field types from the API — maps UI type id to portType primitive. */ /** Canonical form field types from the API — maps UI type id to portType primitive. */
formFieldTypes: FormFieldType[]; formFieldTypes: FormFieldType[];
/** Backend-driven condition operators per valueKind (flow.ifElse). */
conditionOperatorCatalog: Record<string, ConditionOperatorDef[]>;
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string; getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
getAvailableSourceIds: () => string[]; getAvailableSourceIds: () => string[];
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */ /** Present when rendered inside the flow editor (ConnectionPicker / tools). */
@ -44,6 +46,7 @@ interface Automation2DataFlowProviderProps {
portTypeCatalog?: Record<string, PortSchema>; portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>; systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[]; formFieldTypes?: FormFieldType[];
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
instanceId?: string; instanceId?: string;
request?: ApiRequestFunction; request?: ApiRequestFunction;
children: React.ReactNode; children: React.ReactNode;
@ -59,6 +62,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog = {}, portTypeCatalog = {},
systemVariables = {}, systemVariables = {},
formFieldTypes = [], formFieldTypes = [],
conditionOperatorCatalog = {},
instanceId, instanceId,
request, request,
children, children,
@ -120,6 +124,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
portTypeCatalog, portTypeCatalog,
systemVariables, systemVariables,
formFieldTypes, formFieldTypes,
conditionOperatorCatalog,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) => getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id, n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections), getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
@ -127,7 +132,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
request, request,
parseGraphDefinedSchema, parseGraphDefinedSchema,
}; };
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, instanceId, request]); }, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
return ( return (
<Automation2DataFlowContext.Provider value={value}> <Automation2DataFlowContext.Provider value={value}>

View file

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

View file

@ -1,8 +1,8 @@
/** /**
* Automation2FlowEditor * Automation2FlowEditor
* *
* n8n-style flow builder with backend-driven node list. * n8n-style flow builder with backend-driven node list and categories.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync. * Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
*/ */
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@ -32,18 +32,20 @@ import {
type AutoVersion, type AutoVersion,
type AutoTemplateScope, type AutoTemplateScope,
} from '../../../api/workflowApi'; } from '../../../api/workflowApi';
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import {
FlowCanvas,
type CanvasNode,
type CanvasConnection,
type CanvasStickyNote,
type FlowCanvasHandle,
type FlowCanvasViewportEditState,
} from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar'; import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader'; import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker'; import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils'; import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils'; import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry'; import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation'; import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils'; import { getLabel as getParamLabel } from '../nodes/shared/utils';
@ -58,13 +60,23 @@ import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]'; const LOG = '[Automation2]';
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] => const CANVAS_HISTORY_MAX = 50;
buildInvocationsForPrimaryKind('manual', [], runLabel);
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 Automation2FlowEditorProps { interface Automation2FlowEditorProps {
instanceId: string; instanceId: string;
@ -92,7 +104,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSourcesChanged, onSourcesChanged,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt(); const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]); const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
@ -100,24 +111,37 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({}); const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({}); const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]); const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
Record<string, import('../../../api/workflowApi').ConditionOperatorDef[]>
>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>( const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee']) new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
); );
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]); const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]); const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const flowCanvasRef = useRef<FlowCanvasHandle>(null);
const canvasHistoryPastRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const canvasHistoryFutureRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const suppressCanvasHistoryRef = useRef(false);
const [canvasHistoryTick, setCanvasHistoryTick] = useState(0);
const [canvasViewportEdit, setCanvasViewportEdit] = useState<FlowCanvasViewportEditState>({
zoom: 1,
selectedNodeCount: 0,
connectionSelected: false,
stickyNoteSelected: false,
});
const [canvasConnectionToolActive, setCanvasConnectionToolActive] = useState(false);
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]);
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null); const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]); const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null); const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null); const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() => const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true); const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null); const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({}); const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
@ -136,13 +160,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [versionLoading, setVersionLoading] = useState(false); const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId); const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => { const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; } try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
@ -196,7 +213,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
}, [leftPanelWidth, sidebarWidth]); }, [leftPanelWidth, sidebarWidth]);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []); const startNodeTypeIds = useMemo(
() => new Set(nodeTypes.filter((n) => n.category === 'start').map((n) => n.id)),
[nodeTypes]
);
const hasCanvasStartNode = useMemo(
() => canvasNodes.some((n) => startNodeTypeIds.has(n.type)),
[canvasNodes, startNodeTypeIds]
);
const missingStartNodeBlocking = useMemo(
() => canvasNodes.length > 0 && !hasCanvasStartNode,
[canvasNodes.length, hasCanvasStartNode]
);
const nodeOutputsPreview = useMemo( const nodeOutputsPreview = useMemo(
() => () =>
@ -219,22 +247,73 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]); const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]); const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const pushCanvasHistoryPastFromCurrent = useCallback(() => {
if (suppressCanvasHistoryRef.current) return;
const snap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const past = canvasHistoryPastRef.current;
const last = past[past.length - 1];
if (last && JSON.stringify(last) === JSON.stringify(snap)) return;
past.push(snap);
if (past.length > CANVAS_HISTORY_MAX) past.shift();
canvasHistoryFutureRef.current = [];
setCanvasHistoryTick((x) => x + 1);
}, [canvasNodes, canvasConnections]);
const onCanvasHistoryCheckpoint = useCallback(() => {
pushCanvasHistoryPastFromCurrent();
}, [pushCanvasHistoryPastFromCurrent]);
const undoCanvasEdit = useCallback(() => {
const past = canvasHistoryPastRef.current;
if (past.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = past.pop()!;
canvasHistoryFutureRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const redoCanvasEdit = useCallback(() => {
const fut = canvasHistoryFutureRef.current;
if (fut.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = fut.pop()!;
canvasHistoryPastRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const canCanvasUndo = useMemo(() => canvasHistoryPastRef.current.length > 0, [canvasHistoryTick]);
const canCanvasRedo = useMemo(() => canvasHistoryFutureRef.current.length > 0, [canvasHistoryTick]);
const applyGraphWithSync = useCallback( const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => { (
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen')); graph: Automation2Graph | null | undefined,
setInvocations(inv); wfInvocations: WorkflowEntryPoint[] | undefined,
if (!graph?.nodes?.length) { opts?: { skipHistory?: boolean }
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language); ) => {
setCanvasNodes(synced.nodes); if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
setCanvasConnections(synced.connections); pushCanvasHistoryPastFromCurrent();
return;
} }
const { nodes, connections } = fromApiGraph(graph, nodeTypes); setInvocations(wfInvocations ?? []);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language); const g: Automation2Graph = graph ?? { nodes: [], connections: [] };
setCanvasNodes(synced.nodes); const { nodes, connections } = fromApiGraph(g, nodeTypes);
setCanvasConnections(synced.connections); setCanvasNodes(nodes);
setCanvasConnections(connections);
}, },
[nodeTypes, language, t] [nodeTypes, pushCanvasHistoryPastFromCurrent]
); );
const handleFromApiGraph = useCallback( const handleFromApiGraph = useCallback(
@ -263,6 +342,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
}); });
return; return;
} }
if (missingStartNodeBlocking) {
setExecuteResult({
success: false,
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
});
return;
}
setExecuting(true); setExecuting(true);
setExecuteResult(null); setExecuteResult(null);
try { try {
@ -280,7 +366,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setExecuting(false); setExecuting(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections); const graph = toApiGraph(canvasNodes, canvasConnections);
@ -296,19 +382,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
0, 0,
); );
const errorNodeCount = Object.keys(nodeErrors).length; const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => ({ const _buildSaveResult = (): ExecuteGraphResponse => {
success: true, const parts: string[] = [];
warning: if (errorCount > 0) {
errorCount > 0 parts.push(
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.') t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount)) .replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount)) .replace('{m}', String(errorNodeCount))
: undefined, );
}); }
if (canvasNodes.length > 0 && !hasCanvasStartNode) {
parts.push(t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'));
}
return {
success: true,
warning: parts.length ? parts.join(' ') : undefined,
};
};
setSaving(true); setSaving(true);
try { try {
if (currentWorkflowId) { if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId }); const updated = await updateWorkflow(request, instanceId, currentWorkflowId, {
graph,
invocations,
targetFeatureInstanceId,
});
setInvocations(updated.invocations ?? []);
setExecuteResult(_buildSaveResult()); setExecuteResult(_buildSaveResult());
} else { } else {
const label = await promptInput(t('Workflow-Name:'), { const label = await promptInput(t('Workflow-Name:'), {
@ -327,7 +426,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
targetFeatureInstanceId, targetFeatureInstanceId,
}); });
setCurrentWorkflowId(created.id); setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations); setInvocations(created.invocations ?? []);
setWorkflows((prev) => [...prev, created]); setWorkflows((prev) => [...prev, created]);
setExecuteResult(_buildSaveResult()); setExecuteResult(_buildSaveResult());
} }
@ -336,7 +435,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
const handleLoad = useCallback( const handleLoad = useCallback(
async (workflowId: string) => { async (workflowId: string) => {
@ -361,7 +460,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId)); setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev)); setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
try { try {
const result = await fetchWorkflows(request, instanceId); const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items); setWorkflows(Array.isArray(result) ? result : result.items);
@ -385,7 +484,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (workflowId) handleLoad(workflowId); if (workflowId) handleLoad(workflowId);
else { else {
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
} }
}, },
[handleLoad, applyGraphWithSync, t] [handleLoad, applyGraphWithSync, t]
@ -394,36 +493,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleNew = useCallback(() => { const handleNew = useCallback(() => {
setCurrentWorkflowId(null); setCurrentWorkflowId(null);
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
}, [applyGraphWithSync, t]); }, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => { const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) => setCanvasNodes((prev) => {
prev.map((n) => { const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n; if (n.id !== nodeId) return n;
const next = { ...n, parameters }; const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) { if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? []; const newCount = switchOutputCountFromCases(parameters.cases);
next.outputs = Math.max(1, cases.length); next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
} }
return next; return next;
}) });
); return nextNodes;
});
}, []); }, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => { const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) => setCanvasNodes((prev) => {
prev.map((n) => { const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n; if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch }; const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged }; const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) { if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? []; const newCount = switchOutputCountFromCases(merged.cases);
next.outputs = Math.max(1, cases.length); next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
} }
return next; return next;
}) });
); return nextNodes;
});
}, []); }, []);
const handleNodeUpdate = useCallback( const handleNodeUpdate = useCallback(
@ -435,18 +542,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[] []
); );
const handleApplyWorkflowConfiguration = useCallback(
(next: WorkflowEntryPoint[]) => {
setInvocations(next);
setCanvasNodes((nodes) => {
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
setCanvasConnections(r.connections);
return r.nodes;
});
},
[canvasConnections, nodeTypes, language]
);
const loadNodeTypes = useCallback(async () => { const loadNodeTypes = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
setLoading(true); setLoading(true);
@ -461,6 +556,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} }
if (data.systemVariables) setSystemVariables(data.systemVariables); if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes); if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]); setNodeTypes([]);
@ -488,6 +584,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
loadWorkflows(); loadWorkflows();
}, [loadWorkflows]); }, [loadWorkflows]);
useEffect(() => {
setCanvasStickyNotes([]);
}, [currentWorkflowId]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined); const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return; if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
@ -500,7 +600,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (loading || nodeTypes.length === 0) return; if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return; if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return; if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true,
});
}, [ }, [
loading, loading,
nodeTypes.length, nodeTypes.length,
@ -522,7 +624,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleDropNodeType = useCallback( const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => { (nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId); const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return; if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@ -675,31 +776,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[request, instanceId, handleFromApiGraph] [request, instanceId, handleFromApiGraph]
); );
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try {
await updateWorkflow(request, instanceId, workflowId, { label: newName });
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`${LOG} rename failed`, e);
showError(t('Workflow umbenennen fehlgeschlagen: {msg}', { msg }));
}
}, [request, instanceId, showError, t]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
}, [canvasConnections]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
@ -741,7 +820,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language} language={language}
expandedCategories={expandedCategories} expandedCategories={expandedCategories}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle} style={_sidebarStyle}
/> />
); );
@ -749,15 +827,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected = const configurableSelected =
selectedNode && selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) => [
selectedNode.type.startsWith(p) 'input.',
); 'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
const canvasHeaderEdit = useMemo(
() => ({
zoomPercent: Math.round(canvasViewportEdit.zoom * 100),
selectedNodeCount: canvasViewportEdit.selectedNodeCount,
connectionSelected: canvasViewportEdit.connectionSelected,
stickyNoteSelected: canvasViewportEdit.stickyNoteSelected,
connectionToolActive: canvasConnectionToolActive,
canUndo: canCanvasUndo,
canRedo: canCanvasRedo,
onZoomIn: () => flowCanvasRef.current?.zoomIn(),
onZoomOut: () => flowCanvasRef.current?.zoomOut(),
onZoomPercentCommit: (pct: number) => flowCanvasRef.current?.setZoomPercent(pct),
onFitWindow: () => flowCanvasRef.current?.fitWindow(),
onResetView: () => flowCanvasRef.current?.resetView(),
onUndo: undoCanvasEdit,
onRedo: redoCanvasEdit,
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
}),
[
canvasViewportEdit,
canvasConnectionToolActive,
canCanvasUndo,
canCanvasRedo,
undoCanvasEdit,
redoCanvasEdit,
]
);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */} {/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<> {leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}> <div
data-suppress-flow-node-hotkeys=""
style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}
>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => ( {(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button <button
@ -829,15 +953,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNew={handleNew} onNew={handleNew}
onSave={handleSave} onSave={handleSave}
onExecute={handleExecute} onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)} onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)} workspacePanelOpen={leftPanelOpen}
saving={saving} saving={saving}
executing={executing} executing={executing}
hasNodes={canvasNodes.length > 0} hasNodes={canvasNodes.length > 0}
executeBlockedReason={ executeBlockedReason={
hasGraphErrors hasGraphErrors
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.') ? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
: null : missingStartNodeBlocking
? t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.')
: null
} }
onExecuteBlockedClick={() => { onExecuteBlockedClick={() => {
if (firstErrorNodeId) { if (firstErrorNodeId) {
@ -857,17 +983,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSaveAsTemplate={handleSaveAsTemplate} onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving} templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)} onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema} verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema} onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId} canvasEdit={canvasHeaderEdit}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/> />
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}> <div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<FlowCanvas <FlowCanvas
ref={flowCanvasRef}
nodes={canvasNodes} nodes={canvasNodes}
connections={canvasConnections} connections={canvasConnections}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
@ -879,6 +1002,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSelectionChange={setSelectedNode} onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined} highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors} nodeErrors={nodeErrors}
onViewportEditState={setCanvasViewportEdit}
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
onConnectionToolActiveChange={setCanvasConnectionToolActive}
stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes}
onExternalDrop={async (mime, payload) => { onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow' || !instanceId) return false; if (mime !== 'application/json+workflow' || !instanceId) return false;
const p = payload as { files?: Array<{ id: string }> } | undefined; const p = payload as { files?: Array<{ id: string }> } | undefined;
@ -897,6 +1025,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
/> />
</div> </div>
{configurableSelected && selectedNode && ( {configurableSelected && selectedNode && (
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
<Automation2DataFlowProvider <Automation2DataFlowProvider
node={selectedNode} node={selectedNode}
nodes={canvasNodes} nodes={canvasNodes}
@ -907,6 +1036,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
portTypeCatalog={portTypeCatalog as Record<string, never>} portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>} systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes} formFieldTypes={formFieldTypes}
conditionOperatorCatalog={conditionOperatorCatalog}
instanceId={instanceId} instanceId={instanceId}
request={request} request={request}
> >
@ -922,13 +1052,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
verboseSchema={verboseSchema} verboseSchema={verboseSchema}
/> />
</Automation2DataFlowProvider> </Automation2DataFlowProvider>
</div>
)} )}
</div> </div>
</div> </div>
{/* Right panel: Nodes + Tracing tabs */} {/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} /> <div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}> <div
data-suppress-flow-node-hotkeys=""
style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}
>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
<button <button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`} className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
@ -961,12 +1095,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
</div> </div>
<PromptDialog /> <PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker <TemplatePicker
open={templatePickerOpen} open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)} onClose={() => setTemplatePickerOpen(false)}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,82 @@ import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { AccordionList } from '../../UiComponents/AccordionList';
import type { AccordionListItem } from '../../UiComponents/AccordionList';
const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
const CONTEXT_EXTRACT_CHUNK_SET = new Set<string>(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
export function workflowParamUiValue(stored: Record<string, unknown>, param: NodeTypeParameter): unknown {
const raw = stored[param.name];
if (param.required) {
return raw !== undefined && raw !== null ? raw : param.default;
}
return raw;
}
function effectiveSchemaParamString(name: string, currentParams: Record<string, unknown>, nt: NodeType): string {
const raw = currentParams[name];
const s = raw !== undefined && raw !== null ? String(raw) : '';
if (s !== '') return s;
const meta = nt.parameters?.find((p) => p.name === name);
const d = meta?.default;
return d !== undefined && d !== null ? String(d) : '';
}
function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
return (
<span style={{ fontWeight: 700, fontSize: 12 }}>
{param.required ? (
<span style={{ color: 'var(--danger-color, #dc3545)', marginRight: 3 }} title={t('Pflichtfeld')}>
*
</span>
) : null}
{param.name}
</span>
);
}
function verboseSchemaTypeBadge(
verboseSchema: boolean,
param: NodeTypeParameter,
t: (key: string) => string,
): React.ReactElement | null {
if (!verboseSchema || !param.type) return null;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 6,
flexWrap: 'wrap',
minWidth: 0,
}}
>
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
</div>
);
}
interface NodeConfigPanelProps { interface NodeConfigPanelProps {
node: CanvasNode | null; node: CanvasNode | null;
@ -30,6 +106,35 @@ interface NodeConfigPanelProps {
verboseSchema?: boolean; verboseSchema?: boolean;
} }
/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
* (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
* parameter unless the referenced parameter's effective value matches.
*/
export function parameterVisibleForFrontendOptions(
param: NodeTypeParameter,
params: Record<string, unknown>,
nodeType: NodeType,
): boolean {
const fo = param.frontendOptions;
if (!fo || typeof fo !== 'object') return true;
const dependsOnRaw = fo.dependsOn as unknown;
const showWhenRaw = fo.showWhen as unknown;
if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
return true;
}
const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
const rawSibling = params[dependsOnRaw];
const siblingValue =
rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
const fallback =
depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
const effective = siblingValue !== '' ? siblingValue : fallback;
const allowed: string[] = Array.isArray(showWhenRaw)
? showWhenRaw.map((x) => String(x))
: [String(showWhenRaw)];
return allowed.includes(effective);
}
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node, export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
nodeType, nodeType,
language, language,
@ -62,7 +167,32 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
const updateParam = useCallback( const updateParam = useCallback(
(key: string, value: unknown) => { (key: string, value: unknown) => {
setParams((prev) => { setParams((prev) => {
const next = { ...prev, [key]: value }; const next = { ...prev };
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
clearTimeout(notifyParentTimeoutRef.current);
}
notifyParentTimeoutRef.current = setTimeout(() => {
notifyParentTimeoutRef.current = null;
onParametersChange(id, next);
}, 0);
}
return next;
});
},
[onParametersChange]
);
const patchParams = useCallback(
(patch: Record<string, unknown>) => {
setParams((prev) => {
const next = { ...prev, ...patch };
const id = nodeIdRef.current; const id = nodeIdRef.current;
if (id) { if (id) {
if (notifyParentTimeoutRef.current != null) { if (notifyParentTimeoutRef.current != null) {
@ -115,6 +245,139 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
.join('\n'); .join('\n');
}, [requiredErrors, nodeType, language]); }, [requiredErrors, nodeType, language]);
const extractContentAccordionItems = useMemo((): AccordionListItem<string>[] | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
const out: AccordionListItem<string>[] = [];
for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue;
if (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,
]);
if (!node || !nodeType) return null; if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.'); const isTrigger = node.type.startsWith('trigger.');
@ -219,78 +482,88 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong> <strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div> </div>
)} )}
{parameters.map((param: NodeTypeParameter) => { {extractContentAccordionItems !== null ? (
// Safety net: hidden params have no UI footprint at all — no row, <AccordionList<string>
// no required-mark, no type-badge. Their value is system-set. key={`${node.id}-extract-accordion`}
if (param.frontendType === 'hidden') return null; defaultOpenId={null}
const useRequiredPicker = _shouldUseRequiredPicker(param); items={extractContentAccordionItems}
if (useRequiredPicker) { />
) : (
parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return ( return (
<div key={param.name} style={{ marginBottom: 8 }}> <div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<RequiredAttributePicker <div
label={getLabel(param.description, language) || param.name} style={{
expectedType={param.type} display: 'flex',
value={params[param.name] ?? param.default} alignItems: 'center',
onChange={(val) => updateParam(param.name, val)} gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/> />
</div> </div>
); );
} })
const frontendType = param.frontendType || 'text'; )}
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={param.name} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={params[param.name] ?? param.default}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
/>
</div>
);
})}
</div> </div>
); );
}; };
@ -320,6 +593,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'featureInstance', 'featureInstance',
'sharepointFolder', 'sharepointFolder',
'sharepointFile', 'sharepointFile',
'userFileFolder',
'clickupList', 'clickupList',
'clickupTask', 'clickupTask',
'dataRef', 'dataRef',

View file

@ -1,6 +1,6 @@
/** /**
* NodeSidebar - Sidebar with searchable, collapsible node list. * NodeSidebar - Sidebar with searchable, collapsible node list.
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint). * Groups node types by category (start, input, flow, data, ai, email, sharepoint).
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
@ -21,7 +21,7 @@ interface NodeSidebarProps {
language: string; language: string;
expandedCategories: Set<string>; expandedCategories: Set<string>;
onToggleCategory: (id: string) => void; onToggleCategory: (id: string) => void;
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */ /** Hide palette categories (optional; e.g. feature flags) */
excludedCategories?: Set<string>; excludedCategories?: Set<string>;
style?: React.CSSProperties; style?: React.CSSProperties;
} }

View file

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

View file

@ -1,11 +1,12 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor'; export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel'; export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas'; export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas'; export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel'; export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar'; export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem'; export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader'; export { CanvasHeader } from './editor/CanvasHeader';
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
export * from './nodes/shared/utils'; export * from './nodes/shared/utils';
export * from './nodes/shared/constants'; export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils'; export * from './nodes/shared/graphUtils';

View file

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

View file

@ -8,6 +8,12 @@ import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -64,20 +70,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
</span> </span>
<div className={styles.formFieldInputs}> <div className={styles.formFieldInputs}>
<input <input
placeholder={t('name')} placeholder={t('Bezeichnung')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder={t('label')}
value={f.label ?? ''} value={f.label ?? ''}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const next = [...fields]; const next = [...fields];
next[i] = { ...next[i], label: e.target.value }; next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
updateParam('fields', next); updateParam('fields', next);
}} }}
/> />
@ -88,7 +86,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
value={f.type ?? 'text'} value={f.type ?? 'text'}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required }; const type = e.target.value as FormField['type'];
const row: FormField = { ...f, type };
if (formFieldTypeHasConfigurableOptions(type)) {
row.options = normalizeFormFieldOptions(row.options);
}
next[i] = row;
updateParam('fields', next); updateParam('fields', next);
}} }}
style={{ width: 'auto', minWidth: 90 }} style={{ width: 'auto', minWidth: 90 }}
@ -118,12 +121,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
<FaTimes /> <FaTimes />
</button> </button>
</div> </div>
{formFieldTypeHasConfigurableOptions(f.type) ? (
<FormFieldOptionsEditor
className={styles.formFieldOptionsBlock}
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => {
const next = [...fields];
next[i] = { ...next[i], options: opts };
updateParam('fields', next);
}}
/>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }]) updateParam('fields', [
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
type: 'text',
label: '',
required: false,
},
])
} }
> >
+ {t('Feld')} + {t('Feld')}

View file

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

View file

@ -1 +1,8 @@
export { FormNodeConfig } from './FormNodeConfig'; export { FormNodeConfig } from './FormNodeConfig';
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
export type { FormFieldOptionRow } from './formFieldOptionsUtils';
export {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';

View file

@ -0,0 +1,274 @@
/**
* Backend-driven case list for flow.switch (depends on value dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
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 = useAutomation2DataFlow();
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, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => applyMeta(meta.valueKind, meta.operators))
.catch(() => applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown')))
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown'));
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const setCases = (next: SwitchCase[]) => onChange(next);
const addCase = () => {
const opDef = operators[0];
setCases([
...cases,
{
operator: opDef?.id ?? 'eq',
value: opDef?.needsValue ? (valueKind === 'number' ? 0 : '') : undefined,
},
]);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst einen Wert im Data Picker wählen')}
</p>
</div>
);
}
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
{t('Lade Operatoren…')}
</div>
)}
{cases.map((c, i) => {
const opDef = operators.find((o) => o.id === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<select
value={c.operator}
onChange={(e) => {
const op = operators.find((o) => o.id === e.target.value);
const next = [...cases];
next[i] = {
operator: e.target.value,
value: op?.needsValue ? cases[i]?.value ?? '' : undefined,
};
setCases(next);
}}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
{needsValue && (
<CaseValueInput
caseItem={c}
opDef={opDef}
valueKind={valueKind}
t={t}
onChange={(v) => {
const next = [...cases];
next[i] = { ...next[i], value: v };
setCases(next);
}}
/>
)}
<button
type="button"
onClick={() => setCases(cases.filter((_, j) => j !== i))}
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
>
×
</button>
</div>
);
})}
<button
type="button"
onClick={addCase}
disabled={loading || operators.length === 0}
style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}
>
{t('Fall hinzufügen')}
</button>
</div>
);
};

View file

@ -0,0 +1,223 @@
/**
* Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
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 = useAutomation2DataFlow();
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, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => {
applyMeta(meta.valueKind, meta.operators);
})
.catch(() => {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset operators when Item ref changes
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const currentOp = operators.find((o) => o.id === cond.operator) ?? operators[0];
const needsValue = currentOp?.needsValue ?? true;
const valueInput = currentOp?.valueInput;
const setCondition = (next: StructuredCondition) => {
onChange(next);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst ein Item im Data Picker wählen')}
</p>
</div>
);
}
const handleOperatorChange = (opId: string) => {
const opDef = operators.find((o) => o.id === opId);
setCondition({
type: 'condition',
operator: opId,
value: opDef?.needsValue ? cond.value ?? '' : undefined,
});
};
const handleValueChange = (v: string | number) => {
const kind = valueInput?.kind;
const parsed =
kind === 'number' || valueKind === 'number' ? parseFloat(String(v)) || 0 : String(v);
setCondition({ type: 'condition', operator: cond.operator, value: parsed });
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<ConditionRow>
<label>{t('Vergleich')}</label>
<select
value={cond.operator}
onChange={(e) => handleOperatorChange(e.target.value)}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</ConditionRow>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>{t('Lade Operatoren…')}</div>
)}
{needsValue && (
<ConditionRow>
<label>{t('Wert')}</label>
{valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime' ? (
<select
value={String(cond.value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
type={
valueInput?.kind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(cond.value ?? '')}
onChange={(e) =>
handleValueChange(
valueInput?.kind === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
)
}
placeholder={
valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert eingeben')
}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
</ConditionRow>
)}
</div>
);
};
const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6, fontSize: 12 }}>
{children}
</div>
);

View file

@ -0,0 +1,372 @@
/**
* 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 { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
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 = useAutomation2DataFlow();
const rows = normalizeRows(value, allParams);
const [pickerRow, setPickerRow] = React.useState<number | null>(null);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const setRows = (next: ContextAssignmentRow[]) => {
onChange(next.length ? next : [defaultRow()]);
};
const setRow = (idx: number, patch: Partial<ContextAssignmentRow>) => {
const next = [...rows];
next[idx] = { ...next[idx], ...patch };
setRows(next);
};
const addRow = () => setRows([...rows, defaultRow()]);
const removeRow = (idx: number) => {
if (rows.length <= 1) {
onChange([defaultRow()]);
return;
}
setRows(rows.filter((_, i) => i !== idx));
};
const labelForRef = (ref: DataRef | SystemVarRef): string => {
if (isSystemVar(ref)) {
return t('System') + `: ${ref.variable}`;
}
const nodeLabel =
dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
) ?? ref.nodeId;
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
return pathStr ? `${nodeLabel}${pathStr}` : nodeLabel;
};
const onPickRef = (idx: number, picked: DataRef | SystemVarRef) => {
if (!isRef(picked) && !isSystemVar(picked)) return;
setRow(idx, { upstreamRef: picked });
setPickerRow(null);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{rows.map((row, idx) => (
<div key={idx} style={ROW_BOX}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="text"
placeholder={t('Ziel-Schlüssel im Kontext')}
value={row.contextKey}
onChange={(e) => setRow(idx, { contextKey: e.target.value })}
style={{ flex: '2 1 140px', minWidth: 120, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<select
value={row.valueSource}
onChange={(e) => {
const vs = e.target.value as ValueSource;
const patch: Partial<ContextAssignmentRow> = { valueSource: vs };
if (vs === 'literal') patch.upstreamRef = undefined;
if (vs === 'pickUpstream') patch.literal = '';
setRow(idx, patch);
}}
style={{ flex: '1 1 160px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="pickUpstream">{t('Wert aus Daten-Picker')}</option>
<option value="literal">{t('Fester Wert')}</option>
<option value="humanTask">{t('Benutzer setzt Wert (Task)')}</option>
</select>
<select
value={row.mode || 'set'}
onChange={(e) => setRow(idx, { mode: e.target.value as ContextAssignmentRow['mode'] })}
style={{ flex: '1 1 120px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{MODES.map((m) => (
<option key={m.id} value={m.id}>
{m.labelDe}
</option>
))}
</select>
<select
value={row.valueType || 'str'}
onChange={(e) => setRow(idx, { valueType: e.target.value })}
style={{ flex: '0 1 90px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{tp}
</option>
))}
</select>
<button type="button" style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }} onClick={() => removeRow(idx)}>
×
</button>
</div>
{row.valueSource === 'pickUpstream' && (
<div>
{row.upstreamRef && (isRef(row.upstreamRef) || isSystemVar(row.upstreamRef)) && (
<div style={CHIP_STYLE}>
<span style={{ flex: 1, color: '#2d6a2d' }}>{labelForRef(row.upstreamRef)}</span>
<button type="button" style={REMOVE_BTN} onClick={() => setRow(idx, { upstreamRef: undefined })} title={t('Entfernen')}>
×
</button>
</div>
)}
<button
type="button"
onClick={() => setPickerRow(idx)}
disabled={!hasSources}
style={{
marginTop: 4,
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #1c5fb5',
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{hasSources ? t('Datenquelle wählen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
<input
type="text"
placeholder={t('Optional: Zusatz-Pfad (z. B. payload.status)')}
value={row.sourcePath || ''}
onChange={(e) => setRow(idx, { sourcePath: e.target.value })}
style={{ width: '100%', marginTop: 6, padding: '4px 6px', borderRadius: 4, border: '1px dashed #aaa', fontSize: 11 }}
/>
</div>
)}
{row.valueSource === 'literal' && (
<input
type="text"
placeholder={t('Wert (oder JSON für object/list)')}
value={row.literal ?? ''}
onChange={(e) => setRow(idx, { literal: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
{row.valueSource === 'humanTask' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
type="text"
placeholder={t('Titel der Aufgabe (optional)')}
value={row.taskTitle || ''}
onChange={(e) => setRow(idx, { taskTitle: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<textarea
placeholder={t('Beschreibung für den Bearbeiter (optional)')}
value={row.taskDescription || ''}
onChange={(e) => setRow(idx, { taskDescription: e.target.value })}
rows={2}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
/>
</div>
)}
</div>
))}
<button type="button" onClick={addRow} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>
{t('Zuweisung hinzufügen')}
</button>
{dataFlow && pickerRow != null && (
<DataPicker
open
onClose={() => setPickerRow(null)}
onPick={(picked) => onPickRef(pickerRow, picked)}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType="Any"
/>
)}
</div>
);
};

View file

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

View file

@ -8,6 +8,12 @@ import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from '../form/formFieldOptionsUtils';
export interface FieldRendererProps { export interface FieldRendererProps {
param: NodeTypeParameter; param: NodeTypeParameter;
@ -17,6 +23,10 @@ export interface FieldRendererProps {
instanceId?: string; instanceId?: string;
request?: ApiRequestFunction; request?: ApiRequestFunction;
nodeType?: string; nodeType?: string;
/** Atomically merge several parameter keys (e.g. cron + schedule). */
onPatchParams?: (patch: Record<string, unknown>) => void;
/** Hide the prominent ``param.name`` line (e.g. Accordion header already shows it). */
hideAccordionTitle?: boolean;
} }
export type FieldRendererComponent = ComponentType<FieldRendererProps>; export type FieldRendererComponent = ComponentType<FieldRendererProps>;
@ -26,6 +36,13 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import React from 'react'; import React from 'react';
import { SchedulePlanner } from '../../../SchedulePlanner';
import {
buildCronFromSpec,
scheduleSpecFromParams,
scheduleSpecToPersistentJson,
type ScheduleSpec,
} from '../../../../utils/scheduleCron';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils'; import { toApiGraph } from '../shared/graphUtils';
@ -33,7 +50,11 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas'; import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer'; import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer'; import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker'; import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { ConditionEditor } from './ConditionEditor';
import { CaseListEditor } from './CaseListEditor';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config'; import { getApiBaseUrl } from '../../../../../config/config';
@ -98,29 +119,145 @@ const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) =>
</div> </div>
); );
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { /** Backend may send `options: ["a","b"]` or `options: [{ value, label }, ...]` (e.g. context.extractContent). */
const options: string[] = function _normalizedSelectOptions(raw: unknown): Array<{ value: string; label: string }> {
(param.frontendOptions?.options as string[]) || (param.options as string[]) || []; if (!Array.isArray(raw)) return [];
const out: Array<{ value: string; label: string }> = [];
for (const item of raw) {
if (typeof item === 'string') {
out.push({ value: item, label: item });
continue;
}
if (item && typeof item === 'object' && 'value' in item) {
const rec = item as { value?: unknown; label?: unknown };
if (typeof rec.value === 'string') {
const label = typeof rec.label === 'string' && rec.label.length > 0 ? rec.label : rec.value;
out.push({ value: rec.value, label });
}
}
}
return out;
}
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange, hideAccordionTitle }) => {
const { t } = useLanguage();
const options = _normalizedSelectOptions(
param.frontendOptions?.options ?? param.options ?? []
);
const allowClear = !param.required;
const current = value === undefined || value === null || value === '' ? '' : String(value);
const groupId = `select-segment-${param.name.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
const titleId = `${groupId}-title`;
const descId = `${groupId}-desc`;
const showNameLine = !hideAccordionTitle;
const labelledBy = showNameLine
? param.description
? `${titleId} ${descId}`
: titleId
: param.description
? descId
: undefined;
return ( return (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> {showNameLine ? (
<select <div
value={typeof value === 'string' ? value : ''} id={titleId}
onChange={(e) => onChange(e.target.value)} style={{
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }} display: 'block',
fontSize: 12,
fontWeight: 700,
marginBottom: param.description ? 4 : 6,
color: 'var(--text-primary, #212529)',
letterSpacing: '0.01em',
}}
>
{param.name}
</div>
) : null}
{param.description ? (
<div
id={descId}
style={{
display: 'block',
fontSize: 12,
fontWeight: 400,
lineHeight: 1.35,
marginBottom: 6,
color: 'var(--text-secondary, #555)',
}}
>
{param.description}
</div>
) : null}
<div
role="radiogroup"
aria-labelledby={labelledBy ?? undefined}
aria-label={!labelledBy ? param.name : undefined}
aria-required={param.required ? true : undefined}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
alignItems: 'stretch',
}}
> >
<option value=""></option> {options.map((opt) => {
{options.map((opt) => ( const selected = current === opt.value;
<option key={opt} value={opt}>{opt}</option> return (
))} <button
</select> key={opt.value}
type="button"
role="radio"
aria-checked={selected}
title={
allowClear && selected
? t('Erneut klicken, um die Auswahl aufzuheben')
: undefined
}
onClick={() => {
if (allowClear && selected) {
onChange(undefined);
} else {
onChange(opt.value);
}
}}
style={{
flex: '1 1 auto',
minWidth: 'min(100%, 72px)',
maxWidth: '100%',
textAlign: 'center',
padding: '6px 10px',
fontSize: 11,
lineHeight: 1.25,
borderRadius: 6,
border: selected
? '2px solid var(--primary-color, #0d6efd)'
: '1px solid var(--border-color, #ccc)',
background: selected
? 'var(--primary-soft-bg, rgba(13, 110, 253, 0.12))'
: 'var(--panel-subtle-bg, #f8f9fa)',
color: selected
? 'var(--primary-color, #0a58ca)'
: 'var(--text-primary, #212529)',
fontWeight: selected ? 600 : 400,
cursor: 'pointer',
boxShadow: selected ? 'inset 0 0 0 1px rgba(13, 110, 253, 0.15)' : 'none',
transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
}}
>
{opt.label}
</button>
);
})}
</div>
</div> </div>
); );
}; };
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] = const options = _normalizedSelectOptions(
(param.frontendOptions?.options as string[]) || (param.options as string[]) || []; param.frontendOptions?.options ?? param.options ?? []
);
const selected = Array.isArray(value) ? value : []; const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) => { const toggle = (opt: string) => {
const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt]; const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt];
@ -131,9 +268,9 @@ const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> <label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{options.map((opt) => ( {options.map((opt) => (
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}> <label key={opt.value} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} /> <input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt} {opt.label}
</label> </label>
))} ))}
</div> </div>
@ -503,37 +640,6 @@ const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onCh
); );
}; };
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cases = Array.isArray(value) ? value : [];
const addCase = () => onChange([...cases, { operator: 'eq', value: '' }]);
const removeCase = (idx: number) => onChange(cases.filter((_: unknown, i: number) => i !== idx));
const updateCase = (idx: number, field: string, val: unknown) => {
const next = [...cases];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{cases.map((c: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<select value={String(c.operator || 'eq')} onChange={(e) => updateCase(i, 'operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="contains">{t('enthält')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
</select>
<input type="text" value={String(c.value ?? '')} onChange={(e) => updateCase(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<button onClick={() => removeCase(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
<button onClick={addCase} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Fall hinzufügen')}</button>
</div>
);
};
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const ctx = useAutomation2DataFlow(); const ctx = useAutomation2DataFlow();
@ -541,13 +647,35 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
? ctx.formFieldTypes ? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' })); : FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = Array.isArray(value) ? value : []; const fields = Array.isArray(value) ? value : [];
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]); const addField = () => {
const idx = fields.length;
onChange([
...fields,
{ name: deriveFormFieldPayloadKey('', idx), type: 'text', label: '', required: false },
]);
};
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx)); const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
const updateField = (idx: number, field: string, val: unknown) => { const updateField = (idx: number, field: string, val: unknown) => {
const next = [...fields]; const next = [...fields];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val }; next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next); onChange(next);
}; };
const setFieldLabel = (idx: number, label: string) => {
const next = [...fields];
const row = { ...(next[idx] as Record<string, unknown>), label, name: deriveFormFieldPayloadKey(label, idx) };
next[idx] = row;
onChange(next);
};
const setTopFieldType = (idx: number, typeId: string) => {
const next = [...fields];
const cur = { ...(next[idx] as Record<string, unknown>) };
cur.type = typeId;
if (formFieldTypeHasConfigurableOptions(typeId)) {
cur.options = normalizeFormFieldOptions(cur.options);
}
next[idx] = cur;
onChange(next);
};
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd', width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff', fontSize: 12, boxSizing: 'border-box', background: '#fff',
@ -565,7 +693,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
type="text" type="text"
placeholder={t('Bezeichnung (Anzeigename)')} placeholder={t('Bezeichnung (Anzeigename)')}
value={String(f.label ?? '')} value={String(f.label ?? '')}
onChange={(e) => updateField(i, 'label', e.target.value)} onChange={(e) => setFieldLabel(i, e.target.value)}
style={{ ...inputStyle, flex: 1, fontWeight: 500 }} style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
/> />
<button <button
@ -575,21 +703,11 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }} style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
>×</button> >×</button>
</div> </div>
{/* Row 2: Name + Typ + Pflicht */} {/* Row 2: Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
<input
type="text"
placeholder="z.B. customerName"
value={String(f.name ?? '')}
onChange={(e) => updateField(i, 'name', e.target.value)}
style={inputStyle}
/>
</div>
<div> <div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div> <div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div>
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={selectStyle}> <select value={String(f.type ?? 'text')} onChange={(e) => setTopFieldType(i, e.target.value)} style={selectStyle}>
{fieldTypeOptions.map((ft) => ( {fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option> <option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))} ))}
@ -601,6 +719,14 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
Pflicht Pflicht
</label> </label>
</div> </div>
{formFieldTypeHasConfigurableOptions(String(f.type)) ? (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => updateField(i, 'options', opts)}
/>
</div>
) : null}
{String(f.type) === 'group' && ( {String(f.type) === 'group' && (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}> <div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div> <div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
@ -609,11 +735,16 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input <input
type="text" type="text"
placeholder={t('Name')} placeholder={t('Bezeichnung')}
value={String(sub.name ?? '')} value={String(sub.label ?? sub.name ?? '')}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, name: e.target.value }; nextFields[j] = {
...sub,
label,
name: deriveFormFieldPayloadKey(label, j),
};
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ ...inputStyle, flex: 1 }} style={{ ...inputStyle, flex: 1 }}
@ -621,8 +752,16 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<select <select
value={String(sub.type ?? 'text')} value={String(sub.type ?? 'text')}
onChange={(e) => { onChange={(e) => {
const typeId = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, type: e.target.value }; const subRow: Record<string, unknown> = {
...(nextFields[j] as Record<string, unknown>),
type: typeId,
};
if (formFieldTypeHasConfigurableOptions(typeId)) {
subRow.options = normalizeFormFieldOptions(subRow.options);
}
nextFields[j] = subRow;
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ ...selectStyle, flex: 1 }} style={{ ...selectStyle, flex: 1 }}
@ -640,12 +779,31 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }} style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
>×</button> >×</button>
</div> </div>
{formFieldTypeHasConfigurableOptions(String(sub.type)) ? (
<div style={{ marginTop: 6 }}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(sub.options)}
onChange={(opts) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, options: opts };
updateField(i, 'fields', nextFields);
}}
/>
</div>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
const j = nextFields.length;
nextFields.push({
name: deriveFormFieldPayloadKey('', j),
type: 'text',
label: '',
required: false,
});
updateField(i, 'fields', nextFields); updateField(i, 'fields', nextFields);
}} }}
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }} style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
@ -692,47 +850,38 @@ const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
); );
}; };
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, onPatchParams }) => {
const { t } = useLanguage(); const spec = React.useMemo(
() =>
scheduleSpecFromParams({
...(allParams ?? {}),
cron:
(typeof value === 'string' && value
? value
: typeof allParams?.cron === 'string'
? allParams.cron
: '') as string,
} as Record<string, unknown>),
[allParams, value]
);
const handlePlanner = React.useCallback(
(next: ScheduleSpec) => {
const cron = buildCronFromSpec(next);
const schedule = scheduleSpecToPersistentJson(next);
if (onPatchParams) onPatchParams({ cron, schedule });
else onChange(cron);
},
[onChange, onPatchParams]
);
return ( return (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> <label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
<input <SchedulePlanner value={spec} onChange={handlePlanner} />
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={t('0 9 * * *')}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
/>
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
</div> </div>
); );
}; };
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const ConditionBuilder = ConditionEditor;
const { t } = useLanguage();
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val });
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
<option value="contains">{t('enthält')}</option>
<option value="empty">{t('ist leer')}</option>
<option value="not_empty">{t('ist nicht leer')}</option>
<option value="is_true">{t('ist wahr')}</option>
<option value="is_false">{t('ist falsch')}</option>
</select>
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);
};
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -913,10 +1062,12 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
hidden: HiddenInput, hidden: HiddenInput,
dataRef: DataRefRenderer, dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer, contextBuilder: ContextBuilderRenderer,
contextAssignments: ContextAssignmentsEditor,
userConnection: ConnectionPicker, userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker, featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker, sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker, sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker,
clickupList: FolderPicker, clickupList: FolderPicker,
clickupTask: FolderPicker, clickupTask: FolderPicker,
caseList: CaseListEditor, caseList: CaseListEditor,

View file

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

View file

@ -1 +1,2 @@
export { IfElseNodeConfig } from './IfElseNodeConfig'; export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor';
export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor';

View file

@ -1,296 +0,0 @@
/**
* User-friendly schedule cron
* Standard: 5 Felder (minute hour dom month dow), DOW 0=So 6=Sa
* Intervall Sekunden: 6 Felder (sec min hour dom month dow)
*/
export type ScheduleMode = 'daily' | 'weekdays' | 'weekly' | 'calendar' | 'interval';
export type CalendarPeriod = 'monthly' | 'yearly';
/** sek, min, h, T (Tage), a (Jahre) — Cron nur näherungsweise für T/a */
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
export interface ScheduleSpec {
mode: ScheduleMode;
hour: number;
minute: number;
/** 06, cron DOW; nur bei mode === 'weekly' */
weekdays: number[];
/** Monatlich: Tag 131; Jährlich: Tag im gewählten Monat */
monthDay: number;
/** 112, nur bei calendar + yearly */
monthIndex: number;
calendarPeriod: CalendarPeriod;
intervalValue: number;
intervalUnit: IntervalUnit;
}
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
/** Anzeige MoSo (cronDow wie oben) */
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
{ cronDow: 1, label: 'Mo' },
{ cronDow: 2, label: 'Di' },
{ cronDow: 3, label: 'Mi' },
{ cronDow: 4, label: 'Do' },
{ cronDow: 5, label: 'Fr' },
{ cronDow: 6, label: 'Sa' },
{ cronDow: 0, label: 'So' },
];
export function defaultScheduleSpec(): ScheduleSpec {
return {
mode: 'daily',
hour: 8,
minute: 0,
weekdays: [1, 2, 3, 4, 5],
monthDay: 1,
monthIndex: 1,
calendarPeriod: 'monthly',
intervalValue: 15,
intervalUnit: 'minutes',
};
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
export function buildCronFromSpec(spec: ScheduleSpec): string {
const m = clamp(Math.floor(spec.minute), 0, 59);
const h = clamp(Math.floor(spec.hour), 0, 23);
switch (spec.mode) {
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
case 'weekly': {
const days = [...new Set(spec.weekdays)]
.filter((d) => d >= 0 && d <= 6)
.sort((a, b) => {
const order = (x: number) => (x === 0 ? 7 : x);
return order(a) - order(b);
});
if (days.length === 0) return `${m} ${h} * * 1`;
return `${m} ${h} * * ${days.join(',')}`;
}
case 'calendar': {
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
if (spec.calendarPeriod === 'monthly') {
return `${m} ${h} ${dom} * *`;
}
const month = clamp(Math.floor(spec.monthIndex), 1, 12);
return `${m} ${h} ${dom} ${month} *`;
}
case 'interval': {
const v = Math.max(1, Math.floor(spec.intervalValue));
switch (spec.intervalUnit) {
case 'seconds': {
const s = clamp(v, 1, 59);
return `*/${s} * * * * *`;
}
case 'minutes': {
const mm = clamp(v, 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(v, 1, 23);
return `0 */${hh} * * *`;
}
case 'days': {
if (v <= 1) return `0 0 * * *`;
const d = clamp(v, 2, 31);
return `0 0 */${d} * *`;
}
case 'years':
default:
// Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
return `0 0 1 1 *`;
}
}
default:
return `${m} ${h} * * *`;
}
}
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
if (!cron || typeof cron !== 'string') return null;
const p = cron.trim().split(/\s+/);
if (p.length === 6) {
const [secS, minS, hourS, domS, monthS, dowS] = p;
if (
secS.startsWith('*/') &&
minS === '*' &&
hourS === '*' &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(secS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'seconds',
minute: 0,
hour: 0,
};
}
}
return null;
}
if (p.length < 5) return null;
const [minS, hourS, domS, monthS, dowS] = p;
const minute = parseInt(minS, 10);
const hour = parseInt(hourS, 10);
if (Number.isNaN(minute) || Number.isNaN(hour)) return null;
if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
const iv = parseInt(minS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'minutes',
minute: 0,
hour: 0,
};
}
}
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'hours',
minute: 0,
hour: 0,
};
}
}
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'days',
minute: 0,
hour: 0,
};
}
}
if (domS === '*' && dowS === '*') {
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
}
if (domS === '*' && dowS === '1-5') {
return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute };
}
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
const parts = dowS.split(',').map((x) => parseInt(x.trim(), 10));
const days = parts.filter((x) => !Number.isNaN(x) && x >= 0 && x <= 7);
if (days.length > 0) {
const norm = days.map((d) => (d === 7 ? 0 : d));
return {
...defaultScheduleSpec(),
mode: 'weekly',
hour,
minute,
weekdays: norm,
};
}
}
const dom = parseInt(domS, 10);
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'monthly',
hour,
minute,
monthDay: dom,
};
}
if (
!Number.isNaN(dom) &&
dom >= 1 &&
dom <= 31 &&
!Number.isNaN(month) &&
month >= 1 &&
month <= 12 &&
(dowS === '*' || dowS === '?')
) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'yearly',
hour,
minute,
monthDay: dom,
monthIndex: month,
};
}
return null;
}
const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval'];
function normalizeIntervalUnit(u: unknown): IntervalUnit {
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
return 'minutes';
}
/** Liest Spec aus Node-Parametern (schedule-Objekt bevorzugt, sonst Cron parsen) */
export function scheduleSpecFromParams(params: Record<string, unknown>): ScheduleSpec {
const raw = params.schedule;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const o = raw as Record<string, unknown>;
let mode = o.mode as string;
if (mode === 'monthly') {
mode = 'calendar';
}
if (VALID_MODES.includes(mode as ScheduleMode)) {
const base = defaultScheduleSpec();
let calendarPeriod: CalendarPeriod = base.calendarPeriod;
if (mode === 'calendar') {
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
}
return {
mode: mode as ScheduleMode,
hour: clamp(Number(o.hour) || base.hour, 0, 23),
minute: clamp(Number(o.minute) || base.minute, 0, 59),
weekdays: Array.isArray(o.weekdays)
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
: base.weekdays,
monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31),
monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12),
calendarPeriod,
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
intervalUnit: normalizeIntervalUnit(o.intervalUnit),
};
}
}
const cron = typeof params.cron === 'string' ? params.cron : '';
return parseCronToSpec(cron) ?? defaultScheduleSpec();
}

View file

@ -1,222 +0,0 @@
/**
* Single canonical start node on the canvas id and type follow workflow primary entry kind.
*/
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType } from '../../../../api/workflowApi';
import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
import { getLabel } from '../shared/utils';
export const CANVAS_START_NODE_ID = 'start';
/** Primary entry is always the first invocation (gear configures index 0). */
export function getPrimaryEntry(invocations: WorkflowEntryPoint[] | undefined): WorkflowEntryPoint | undefined {
return invocations?.[0];
}
/** Kind of the primary entry (drives canvas node type) */
export function getPrimaryStartKind(invocations: WorkflowEntryPoint[] | undefined): string {
return getPrimaryEntry(invocations)?.kind ?? 'manual';
}
function entryTitle(entry: WorkflowEntryPoint | undefined, language: string): string {
if (!entry?.title) return '';
const t = entry.title;
if (typeof t === 'string') return t.trim();
const s = t[language] || t.de || t.en || Object.values(t)[0];
return (s != null ? String(s) : '').trim();
}
export function mapKindToNodeType(kind: string): string {
if (kind === 'form') return 'trigger.form';
if (kind === 'schedule') return 'trigger.schedule';
// Immer aktiv: zunächst Standard-Start; Listener (E-Mail, Webhook, …) folgt separat
if (kind === 'always_on') return 'trigger.manual';
return 'trigger.manual';
}
function categoryForKind(kind: string): 'on_demand' | 'always_on' {
if (kind === 'manual' || kind === 'form') return 'on_demand';
return 'always_on';
}
function titleForStartNode(
kind: string,
invocations: WorkflowEntryPoint[],
nodeTypes: NodeType[],
language: string
): string {
const custom = entryTitle(getPrimaryEntry(invocations), language);
if (custom) return custom;
const nt = nodeTypes.find((n) => n.id === mapKindToNodeType(kind));
if (nt) return getLabel(nt.label, language);
return 'Start';
}
/** Rewire connections when replacing node ids */
function rewireConnections(
connections: CanvasConnection[],
fromId: string,
toId: string
): CanvasConnection[] {
if (fromId === toId) return connections;
return connections.map((c) => ({
...c,
sourceId: c.sourceId === fromId ? toId : c.sourceId,
targetId: c.targetId === fromId ? toId : c.targetId,
}));
}
/** Deep-rewrite ref.nodeId in parameters (e.g. flow.ifElse condition.ref) */
function rewireRefInParams(params: unknown, fromIds: Set<string>, toId: string): unknown {
if (params == null) return params;
if (typeof params === 'object' && params !== null && 'type' in params && 'nodeId' in params) {
const obj = params as { type?: string; nodeId?: string; path?: unknown };
if (obj.type === 'ref' && typeof obj.nodeId === 'string' && fromIds.has(obj.nodeId)) {
return { ...obj, nodeId: toId };
}
}
if (Array.isArray(params)) {
return params.map((item) => rewireRefInParams(item, fromIds, toId));
}
if (typeof params === 'object' && params !== null) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) {
out[k] = rewireRefInParams(v, fromIds, toId);
}
return out;
}
return params;
}
/** Rewrite refs in all nodes' parameters when trigger id changes */
function rewireRefsInNodes(
nodes: CanvasNode[],
fromIds: Set<string>,
toId: string
): CanvasNode[] {
if (fromIds.size === 0) return nodes;
return nodes.map((n) => {
const p = n.parameters;
if (!p || typeof p !== 'object') return n;
const next = rewireRefInParams(p, fromIds, toId);
if (next === p) return n;
return { ...n, parameters: next as Record<string, unknown> };
});
}
/** Remove duplicate trigger nodes; keep first, merge connections onto it */
function dedupeTriggers(
nodes: CanvasNode[],
connections: CanvasConnection[]
): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
const triggers = nodes.filter((n) => n.type.startsWith('trigger.'));
if (triggers.length <= 1) return { nodes, connections };
const keep = triggers[0];
const removeIds = new Set(triggers.slice(1).map((n) => n.id));
let nextConn = connections;
for (const rid of removeIds) {
nextConn = rewireConnections(nextConn, rid, keep.id);
}
const newNodes = nodes.filter((n) => !removeIds.has(n.id));
return { nodes: newNodes, connections: nextConn };
}
/** Normalize canonical id `start` and update type/labels from primary kind */
export function syncCanvasStartNode(
nodes: CanvasNode[],
connections: CanvasConnection[],
invocations: WorkflowEntryPoint[],
nodeTypes: NodeType[],
language: string
): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
const kind = getPrimaryStartKind(invocations);
const targetType = mapKindToNodeType(kind);
const title = titleForStartNode(kind, invocations, nodeTypes, language);
const nt = nodeTypes.find((n) => n.id === targetType);
const inputs = nt?.inputs ?? 0;
const outputs = nt?.outputs ?? 1;
const triggerIdsBeforeDedupe = new Set(nodes.filter((n) => n.type.startsWith('trigger.')).map((n) => n.id));
let { nodes: ns, connections: cs } = dedupeTriggers(nodes, connections);
let startIdx = ns.findIndex((n) => n.type.startsWith('trigger.'));
if (startIdx === -1) {
const newNode: CanvasNode = {
id: CANVAS_START_NODE_ID,
type: targetType,
x: 100,
y: 120,
title,
label: title,
inputs,
outputs,
color: nt?.meta?.color as string | undefined,
parameters: {},
};
ns = rewireRefsInNodes([newNode, ...ns], triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
return { nodes: ns, connections: cs };
}
const current = ns[startIdx];
const oldId = current.id;
let nextConn = cs;
if (oldId !== CANVAS_START_NODE_ID) {
nextConn = rewireConnections(nextConn, oldId, CANVAS_START_NODE_ID);
}
ns = rewireRefsInNodes(ns, triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
const updated: CanvasNode = {
...current,
id: CANVAS_START_NODE_ID,
type: targetType,
title,
label: title,
inputs,
outputs,
color: nt?.meta?.color as string | undefined,
parameters:
targetType === current.type ? current.parameters ?? {} : preserveParametersForTypeSwitch(current, targetType),
};
const nextNodes = [...ns];
nextNodes[startIdx] = updated;
return { nodes: nextNodes, connections: nextConn };
}
function preserveParametersForTypeSwitch(node: CanvasNode, newType: string): Record<string, unknown> {
const p = node.parameters ?? {};
if (newType === 'trigger.form' && p.formFields) return { formFields: p.formFields };
if (newType === 'trigger.schedule' && (p.cron || p.schedule)) {
const out: Record<string, unknown> = {};
if (p.cron != null) out.cron = p.cron;
if (p.schedule != null) out.schedule = p.schedule;
return out;
}
return {};
}
/** Build invocations: replace primary (index 0), keep further entries (e.g. listener config later). */
export function buildInvocationsForPrimaryKind(
kind: string,
existing: WorkflowEntryPoint[] | undefined,
titleDe: string
): WorkflowEntryPoint[] {
const list = existing ?? [];
const primaryId =
list[0]?.id ??
(typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `ep-${Date.now()}`);
const category = categoryForKind(kind);
const primary: WorkflowEntryPoint = {
id: primaryId,
kind,
category,
enabled: true,
title: { de: titleDe, en: titleDe, fr: titleDe },
description: {},
config: {},
};
const rest = list.slice(1).filter((x) => x.id !== primaryId);
return [primary, ...rest];
}

View file

@ -1,194 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.2 / A1.3
// T7: DataPicker strict-type filtering (only compatible candidates rendered).
// T8: DataPicker generic object drill-down via wildcard '*' segment when the
// schema declares List[X] of a known X.
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 { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
import { DataPicker } from './DataPicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
_field('meta', 'str'),
],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.filter((n) => n.id !== opts.consumerNodeId).map((n) => n.id),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRef | SystemVarRef) => void }) {
const upstream = _node('up', 'sharepoint.readDocs');
const consumer = _node('cons', 'ai.summarize');
_setContext({
consumerNodeId: 'cons',
nodes: [upstream, consumer],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [_nodeType('sharepoint.readDocs', 'DocumentList'), _nodeType('ai.summarize', 'AiResult')],
});
return render(
<DataPicker
open
onClose={() => {}}
onPick={props?.onPick ?? (() => {})}
availableSourceIds={['up']}
nodes={[upstream]}
nodeOutputsPreview={{}}
getNodeLabel={(n) => n.title ?? n.id}
expectedParamType={props?.expectedParamType}
/>,
);
}
// ---------------------------------------------------------------------------
// T8: Wildcard drill-down
// ---------------------------------------------------------------------------
describe('DataPicker — generic-object drill-down (T8)', () => {
it('renders the wildcard "documents → * → name" path when drilling into List[UdmDocument]', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument();
expect(screen.getByText(/documents → \* → mimeType/)).toBeInTheDocument();
});
it('lists the wholeOutput, top-level fields, and drilled fields together', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
// Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error).
expect(screen.getAllByText(/documents → \*/).length).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// T7: Strict type filter
// ---------------------------------------------------------------------------
describe('DataPicker — strict type filtering (T7)', () => {
it('hides hard-mismatch fields when expectedParamType is set + strict toggle is on (default)', async () => {
_renderPicker({ expectedParamType: 'str' });
expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked();
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is a hard mismatch → must be hidden.
expect(screen.queryByText('documents')).not.toBeInTheDocument();
// meta (str) is exact match → kept.
expect(screen.getByText('meta')).toBeInTheDocument();
// count (int) is "coerce" against str → kept (coerce is allowed in strict mode).
expect(screen.getByText('count')).toBeInTheDocument();
// Drilled wildcard candidates of type str (name, mimeType) remain.
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument();
});
it('shows all fields after the user disables the strict toggle', async () => {
_renderPicker({ expectedParamType: 'str' });
await userEvent.click(screen.getByLabelText(/Nur kompatible/i));
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
});
it('shows the iterieren-button on List[X] candidates that match expectedParamType=X (T6)', async () => {
_renderPicker({ expectedParamType: 'UdmDocument' });
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is the only candidate with expectedParamType=UdmDocument
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('iterieren')).toBeInTheDocument();
});
it('emits a wildcard ref when the user clicks "iterieren"', async () => {
const onPick = vi.fn();
_renderPicker({ expectedParamType: 'UdmDocument', onPick });
await userEvent.click(screen.getByText(/^up$/));
await userEvent.click(screen.getByText('iterieren'));
expect(onPick).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ref',
nodeId: 'up',
path: ['documents', '*'],
expectedType: 'UdmDocument',
}),
);
});
});

View file

@ -1,16 +1,17 @@
/** /**
* Automation2 Flow Editor - Schema-based Data Picker. * Automation2 Flow Editor - Schema-based Data Picker.
* Builds pickable paths from portTypeCatalog + node outputPorts. * Builds pickable paths from portTypeCatalog + node outputPorts, or from
* outputPorts[n].dataPickOptions when the backend defines an explicit list (authoritative).
* Resolves Transit chains to show the real upstream schema. * Resolves Transit chains to show the real upstream schema.
* Includes a System Variables section. * Includes a System Variables section.
*/ */
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef'; import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi'; import type { DataPickOption, GraphDataSources, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import { findLoopAncestorIds } from './scopeHelpers'; import { fetchGraphDataSources } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -39,14 +40,28 @@ interface PickablePath {
typeMismatch?: boolean; typeMismatch?: boolean;
/** Surfaced at the top of the list as the most common / recommended pick. */ /** Surfaced at the top of the list as the most common / recommended pick. */
recommended?: boolean; recommended?: boolean;
/** Tooltip (Katalog oder Backend-Hinweistext). */
detail?: string;
} }
const _LIST_INNER_RE = /^List\[(.+)\]$/; const _LIST_INNER_RE = /^List\[(.+)\]$/;
function _fieldSegHuman(field: PortField): string {
const picker = field.pickerLabel;
if (typeof picker === 'string' && picker.trim()) return picker.trim();
return field.name;
}
function _detailFromField(description: unknown): string | undefined {
if (typeof description === 'string' && description.trim()) return description.trim();
return undefined;
}
function _buildPathsFromSchema( function _buildPathsFromSchema(
schema: PortSchema | undefined, schema: PortSchema | undefined,
catalog: Record<string, PortSchema>, catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [], basePath: (string | number)[] = [],
baseSegments: string[] = [],
depth = 0, depth = 0,
): PickablePath[] { ): PickablePath[] {
if (!schema || !schema.fields || depth > 8) return []; if (!schema || !schema.fields || depth > 8) return [];
@ -64,21 +79,43 @@ function _buildPathsFromSchema(
} }
for (const field of schema.fields) { for (const field of schema.fields) {
const segHuman = _fieldSegHuman(field);
const fieldPath = [...basePath, field.name]; const fieldPath = [...basePath, field.name];
const label = fieldPath.map(String).join(' → '); const label =
result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false }); baseSegments.length > 0
? `${baseSegments.join(' ')} ${segHuman}`
: segHuman;
const detail = _detailFromField(field.description);
result.push({
path: fieldPath,
label,
type: field.type,
recommended: field.recommended ?? false,
detail,
});
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null; const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
const inner = m?.[1]?.trim(); const inner = m?.[1]?.trim();
if (inner && catalog[inner]) { if (inner && catalog[inner]) {
// Generic List drill-down: use '*' wildcard so the engine maps each item. const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : '';
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1)); const itemBridge = pil || '*';
const nextSegments = [...baseSegments, segHuman, itemBridge];
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], nextSegments, depth + 1));
} }
} }
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' }); result.push({
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' }); path: [...basePath, '_success'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Erfolgskennzeichen` : '_success',
type: 'bool',
});
result.push({
path: [...basePath, '_error'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Fehlermeldung` : '_error',
type: 'str',
});
return result; return result;
} }
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the /** Annotate each candidate with `iterable=true` if it is `List[X]` and the
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */ * consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] { function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
@ -162,6 +199,18 @@ function _buildPathsFromPreview(
return [{ path: [...basePath], label: pathLabel }]; return [{ path: [...basePath], label: pathLabel }];
} }
/** Gateway ``outputPorts[n].dataPickOptions`` — authoritative; no client-side catalog merge. */
function _pathsFromDataPickOptions(options: DataPickOption[]): PickablePath[] {
return options.map((o) => ({
path: [...o.path],
label: o.pickerLabel,
type: o.type,
recommended: Boolean(o.recommended),
iterable: Boolean(o.iterable),
detail: typeof o.detail === 'string' ? o.detail.trim() : undefined,
}));
}
function _resolveSchemaForNode( function _resolveSchemaForNode(
nodeId: string, nodeId: string,
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>, nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
@ -227,20 +276,43 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
// other hook) below it would change the hook count when the picker toggles // other hook) below it would change the hook count when the picker toggles
// open/closed and crash the whole tree (white screen). // open/closed and crash the whole tree (white screen).
const connectionsRaw = ctx?.connections ?? []; const connectionsRaw = ctx?.connections ?? [];
const nodesRaw = ctx?.nodes ?? [];
// sourceHandle is a flat handle index (inputs first, then outputs).
// The backend expects sourceOutput as an output-port index (0-based after inputs).
const nodeInputsById = useMemo(
() => new Map(nodesRaw.map((n) => [n.id, n.inputs ?? 0])),
[nodesRaw],
);
const connections = useMemo( const connections = useMemo(
() => () =>
connectionsRaw.map((c) => ({ connectionsRaw.map((c) => ({
source: c.sourceId, source: c.sourceId,
target: c.targetId, target: c.targetId,
sourceOutput: c.sourceHandle, sourceOutput: c.sourceHandle - (nodeInputsById.get(c.sourceId) ?? 0),
targetInput: c.targetHandle,
})), })),
[connectionsRaw], [connectionsRaw, nodeInputsById],
); );
const loopAncestorIds = useMemo(() => {
const cid = ctx?.currentNodeId; // Fetch scope data from the backend when the picker opens — zero topology logic in JS.
if (!cid) return [] as string[]; const [scopeData, setScopeData] = useState<GraphDataSources | null>(null);
return findLoopAncestorIds(nodes, connections, cid); const scopeFetchKey = useRef<string>('');
}, [ctx?.currentNodeId, nodes, connections]); useEffect(() => {
if (!open || !ctx?.instanceId || !ctx?.request || !ctx?.currentNodeId) return;
const key = `${ctx.instanceId}:${ctx.currentNodeId}:${connections.length}:${(ctx.nodes ?? []).length}`;
if (scopeFetchKey.current === key) return; // already fetched for this state
scopeFetchKey.current = key;
const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type }));
fetchGraphDataSources(ctx.request, ctx.instanceId, ctx.currentNodeId, nodeShapes, connections)
.then(setScopeData)
.catch(() => setScopeData(null));
}, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]);
// Derived: effective source ids and loop context — use backend result when available,
// fall back to the prop (e.g. in tests or offline).
const effectiveSourceIds = scopeData?.availableSourceIds ?? availableSourceIds;
const portIndexOverrides = scopeData?.portIndexOverrides ?? {};
const loopBodyContextIds = scopeData?.loopBodyContextIds ?? [];
if (!open) return null; if (!open) return null;
@ -321,18 +393,18 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</div> </div>
<div className={styles.dataPickerBody}> <div className={styles.dataPickerBody}>
{/* System Variables Section */} {/* System Variables Section */}
{loopAncestorIds.length > 0 && ( {loopBodyContextIds.length > 0 && (
<div className={styles.dataPickerNodeSection}> <div className={styles.dataPickerNodeSection}>
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}> <div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span> <span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
</div> </div>
<div className={styles.dataPickerTree}> <div className={styles.dataPickerTree}>
{loopAncestorIds.map((loopId) => { {loopBodyContextIds.map((loopId) => {
const loopNode = nodes.find((n) => n.id === loopId); const loopNode = nodes.find((n) => n.id === loopId);
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId; const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
const loopSchema = catalog.LoopItem; const loopSchema = catalog.LoopItem;
const loopPaths = loopSchema const loopPaths = loopSchema
? _buildPathsFromSchema(loopSchema, catalog, [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_')) ? _buildPathsFromSchema(loopSchema, catalog, [], [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
: [ : [
{ path: ['currentItem'], label: 'currentItem', type: 'Any' }, { path: ['currentItem'], label: 'currentItem', type: 'Any' },
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' }, { path: ['currentIndex'], label: 'currentIndex', type: 'int' },
@ -407,7 +479,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
{/* Node outputs */} {/* Node outputs */}
{(() => { {(() => {
const filteredIds = availableSourceIds.filter((nodeId) => { const filteredIds = effectiveSourceIds.filter((nodeId) => {
const node = nodes.find((n) => n.id === nodeId); const node = nodes.find((n) => n.id === nodeId);
return node?.type !== 'trigger.manual'; return node?.type !== 'trigger.manual';
}); });
@ -423,24 +495,35 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
const typeLabel = nodeTypeDef?.label ?? node?.type ?? ''; const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId); const isExpanded = expandedNodes.has(nodeId);
const resolvedSchema = _resolveSchemaForNode( // Use the port index the backend says to use (e.g. 1 for loop on Done branch)
nodeId, const portIdx = portIndexOverrides[nodeId] ?? 0;
nodes, const portDef = nodeTypeDef?.outputPorts?.[portIdx];
nodeTypes, const backendPick =
connections, portDef?.dataPickOptions &&
catalog, Array.isArray(portDef.dataPickOptions) &&
new Set(), portDef.dataPickOptions.length > 0;
formTypeToPort,
); let schemaPaths: PickablePath[];
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog); if (backendPick) {
schemaPaths = _pathsFromDataPickOptions(portDef!.dataPickOptions!);
} else {
const resolvedSchema = _resolveSchemaForNode(
nodeId,
nodes,
nodeTypes,
connections,
catalog,
new Set(),
formTypeToPort,
);
schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
}
const annotated = _markIterableCandidates( const annotated = _markIterableCandidates(
schemaPaths.length > 0 schemaPaths.length > 0
? schemaPaths ? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')), : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType, expectedParamType,
); );
// Always show all paths; mark mismatches as a visual warning instead of hiding them.
// Recommended entries bubble to the top.
const markedPaths = annotated.map((p) => ({ const markedPaths = annotated.map((p) => ({
...p, ...p,
typeMismatch: typeMismatch:
@ -450,7 +533,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
!p.iterable && !p.iterable &&
isCompatible(p.type!, expectedParamType!) === 'mismatch', isCompatible(p.type!, expectedParamType!) === 'mismatch',
})); }));
const paths = [ const orderedPaths = [
...markedPaths.filter((p) => p.recommended), ...markedPaths.filter((p) => p.recommended),
...markedPaths.filter((p) => !p.recommended), ...markedPaths.filter((p) => !p.recommended),
]; ];
@ -472,56 +555,55 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</button> </button>
{isExpanded && ( {isExpanded && (
<div className={styles.dataPickerTree}> <div className={styles.dataPickerTree}>
{paths.length === 0 && ( {orderedPaths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}> <div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
{t('(keine Felder verfügbar)')} {t('(keine Felder verfügbar)')}
</div> </div>
)} )}
{paths.map((p, i) => { {orderedPaths.map((p, i) => (
return ( <div
<div key={`${p.path.join('.')}-${i}`}
key={`${p.path.join('.')}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4 }}
style={{ display: 'flex', alignItems: 'center', gap: 4 }} >
<button
type="button"
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
style={{ flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)}
title={p.detail || p.label}
> >
{p.label}
{p.recommended && (
<span className={styles.dataPickerRecommendedPill}>
{t('Empfohlen')}
</span>
)}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{p.typeMismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
{p.iterable && (
<button <button
type="button" type="button"
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`} className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
style={{ flex: 1 }} onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
onClick={() => handlePick(nodeId, p.path, p.type)} title={t('Pro Element der Liste iterieren (Loop)')}
> >
{p.label} {t('iterieren')}
{p.recommended && (
<span className={styles.dataPickerRecommendedPill}>
{t('Empfohlen')}
</span>
)}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{p.typeMismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button> </button>
{p.iterable && ( )}
<button </div>
type="button" ))}
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
title={t('Pro Element der Liste iterieren (Loop)')}
>
{t('iterieren')}
</button>
)}
</div>
);
})}
</div> </div>
)} )}
</div> </div>

View file

@ -1,243 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.1: Component-level tests for RequiredAttributePicker.
// Validates the 0/1/N rendering logic that orchestrates DataPicker selection
// + the iterierens-suggestion (T5, T6).
//
// We mock the two consumed contexts (LanguageContext + Automation2DataFlow)
// and the DataPicker child so we can assert on the picker UI in isolation.
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 { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
// ---------------------------------------------------------------------------
// Module mocks — must be registered before importing the SUT
// ---------------------------------------------------------------------------
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
vi.mock('./DataPicker', () => ({
DataPicker: (props: {
open: boolean;
onClose: () => void;
onPick: (ref: DataRef | SystemVarRef) => void;
}) => {
if (!props.open) return null;
return (
<div data-testid="mock-data-picker">
<button
type="button"
onClick={() => {
props.onPick({ type: 'ref', nodeId: 'picked', path: [], expectedType: 'DocumentList' });
props.onClose();
}}
>
mock-pick
</button>
<button type="button" onClick={props.onClose}>
mock-close
</button>
</div>
);
},
}));
// SUT imported AFTER mocks (so mocks are applied)
import { RequiredAttributePicker } from './RequiredAttributePicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [_field('documents', 'List[UdmDocument]'), _field('count', 'int')],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [_field('name', 'str'), _field('mimeType', 'str')],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.map((n) => n.id).filter((id) => id !== opts.consumerNodeId),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RequiredAttributePicker — 0/1/N rendering (T5/T6)', () => {
it('shows red "no source" pill when no upstream candidate matches (0-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('cons', 'ai.summarizeDocument')],
connections: [],
nodeTypes: [_nodeType('ai.summarizeDocument', 'AiResult')],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(
screen.getByText(/Keine typkompatible Quelle vorhanden/i),
).toBeInTheDocument();
});
it('shows auto-bind suggestion when exactly one candidate matches (1-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/Vorschlag übernehmen/i)).toBeInTheDocument();
});
it('shows iterieren-suggestion when upstream is List[X] and required is X (T6)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Single document"
expectedType="UdmDocument"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/iterieren/i)).toBeInTheDocument();
});
it('renders bound chip + "Andere wählen" when value is already a DataRef', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
expect(screen.getByText('up')).toBeInTheDocument();
const clearButton = screen.getByTitle(/Bindung entfernen/i);
await userEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith(null);
});
it('opens DataPicker via "Andere wählen" and forwards the picked ref to onChange', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
const otherButton = screen.getByText(/Andere wählen…/i);
await userEvent.click(otherButton);
expect(screen.getByTestId('mock-data-picker')).toBeInTheDocument();
await userEvent.click(screen.getByText('mock-pick'));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ref', nodeId: 'picked', expectedType: 'DocumentList' }),
);
});
});

View file

@ -6,7 +6,7 @@ import React from 'react';
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa'; import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa';
export const CATEGORY_ICONS: Record<string, React.ReactNode> = { export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
trigger: <FaPlay />, start: <FaPlay />,
input: <FaUser />, input: <FaUser />,
flow: <FaCodeBranch />, flow: <FaCodeBranch />,
data: <FaDatabase />, data: <FaDatabase />,

View file

@ -8,7 +8,7 @@ export const HIDDEN_NODE_IDS = new Set<string>();
/** Default category display order */ /** Default category display order */
export const CATEGORY_ORDER = [ export const CATEGORY_ORDER = [
'trigger', 'start',
'input', 'input',
'flow', 'flow',
'data', 'data',

View file

@ -12,6 +12,39 @@ import type {
} from '../../../../api/workflowApi'; } from '../../../../api/workflowApi';
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas'; import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
/** Switch: one output per case plus a default (``Sonst``) port. */
export function switchOutputCountFromCases(cases: unknown): number {
const n = Array.isArray(cases) ? cases.length : 0;
return Math.max(1, n + 1);
}
/** Drop edges from switch output ports that no longer exist after case removal. */
export function trimConnectionsForSwitchOutputs(
connections: CanvasConnection[],
nodeId: string,
nodeInputs: number,
outputCount: number
): CanvasConnection[] {
return connections.filter((c) => {
if (c.sourceId !== nodeId) return true;
const outIdx = c.sourceHandle - nodeInputs;
return outIdx >= 0 && outIdx < outputCount;
});
}
export function switchOutputLabel(
node: CanvasNode,
outputIndex: number,
translate: (key: string) => string
): string | undefined {
if (node.type !== 'flow.switch') return undefined;
const cases = (node.parameters?.cases as unknown[]) ?? [];
const caseCount = Array.isArray(cases) ? cases.length : 0;
if (outputIndex < caseCount) return `${translate('Fall')} ${outputIndex + 1}`;
if (outputIndex === caseCount) return translate('Sonst');
return undefined;
}
export function fromApiGraph( export function fromApiGraph(
graph: Automation2Graph, graph: Automation2Graph,
nodeTypes: NodeType[] nodeTypes: NodeType[]
@ -26,7 +59,7 @@ export function fromApiGraph(
let outputs = io.outputs; let outputs = io.outputs;
if (n.type === 'flow.switch') { if (n.type === 'flow.switch') {
const cases = (n.parameters?.cases as unknown[]) ?? []; const cases = (n.parameters?.cases as unknown[]) ?? [];
outputs = Math.max(1, cases.length); outputs = switchOutputCountFromCases(cases);
} }
const nt = nodeTypes.find((t) => t.id === n.type); const nt = nodeTypes.find((t) => t.id === n.type);
return { return {

View file

@ -1,318 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1 / FE-Tests
// T5/T6 (RequiredAttributePicker): 0/1/N candidate logic + iterierens-suggestion
// T7 (DataPicker): strict type filtering
// T8 (DataPicker): generic-object drill-down via wildcard segment '*'
//
// We test the pure helpers in paramValidation.ts directly. The component
// pickers are thin shells over these helpers, so covering the helpers covers
// the deterministic core of the binding affordance.
import { describe, expect, it } from 'vitest';
import {
findGraphErrors,
findRequiredErrors,
findSourceCandidates,
isParamBound,
strictlyCompatible,
type SourceCandidate,
} from './paramValidation';
import { createRef, createSystemVar, createValue } from './dataRef';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
function _schema(name: string, fields: PortField[]): PortSchema {
return { name, fields };
}
const _docListSchema: PortSchema = _schema('DocumentList', [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
]);
const _udmDocumentSchema: PortSchema = _schema('UdmDocument', [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
]);
const _aiResultSchema: PortSchema = _schema('AiResult', [
_field('text', 'str'),
_field('tokensUsed', 'int'),
]);
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
AiResult: _aiResultSchema,
};
function _makeNode(id: string, type: string, parameters: Record<string, unknown> = {}): CanvasNode {
return {
id,
type,
title: `${id} (${type})`,
x: 0,
y: 0,
inputs: 1,
outputs: 1,
parameters,
};
}
function _makeConnection(id: string, sourceId: string, targetId: string): CanvasConnection {
return {
id,
sourceId,
sourceHandle: 0,
targetId,
targetHandle: 0,
};
}
function _makeNodeType(
id: string,
outputSchema: string,
parameters: NodeType['parameters'] = [],
): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters,
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// isParamBound
// ---------------------------------------------------------------------------
describe('isParamBound', () => {
it('returns false for null/undefined/empty string', () => {
expect(isParamBound(null)).toBe(false);
expect(isParamBound(undefined)).toBe(false);
expect(isParamBound('')).toBe(false);
});
it('returns true for non-empty string/number/boolean', () => {
expect(isParamBound('hello')).toBe(true);
expect(isParamBound(0)).toBe(true);
expect(isParamBound(false)).toBe(true);
});
it('returns true for a valid DataRef and false for one without nodeId', () => {
expect(isParamBound(createRef('node-1', ['x']))).toBe(true);
expect(isParamBound({ type: 'ref', nodeId: '', path: [] })).toBe(false);
});
it('returns true for a SystemVarRef with a variable name', () => {
expect(isParamBound(createSystemVar('user.id'))).toBe(true);
expect(isParamBound({ type: 'system', variable: '' })).toBe(false);
});
it('treats {type:"value", value:""} as unbound but {value:0} as bound', () => {
expect(isParamBound(createValue(''))).toBe(false);
expect(isParamBound(createValue(0))).toBe(true);
expect(isParamBound(createValue([]))).toBe(false);
expect(isParamBound(createValue(['a']))).toBe(true);
});
it('counts non-empty arrays/objects as bound', () => {
expect(isParamBound([])).toBe(false);
expect(isParamBound([1])).toBe(true);
expect(isParamBound({})).toBe(false);
expect(isParamBound({ k: 1 })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// findRequiredErrors / findGraphErrors
// ---------------------------------------------------------------------------
describe('findRequiredErrors', () => {
it('returns empty when all required params are bound', () => {
const node = _makeNode('n1', 'ai.process', {
aiPrompt: 'hello',
documentList: createRef('upstream', ['documents']),
});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
expect(findRequiredErrors(node, nodeType)).toEqual([]);
});
it('flags every unbound required param with its name + type', () => {
const node = _makeNode('n1', 'ai.process', {});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(2);
expect(errs.map((e) => e.paramName)).toEqual(['aiPrompt', 'documentList']);
});
it('returns empty list when nodeType is unknown', () => {
const node = _makeNode('n1', 'ghost.node');
expect(findRequiredErrors(node, undefined)).toEqual([]);
});
it('skips required params with frontendType="hidden" (UI safety net)', () => {
// Hidden params have no UI surface, so reporting them as
// "Pflichtfeld ohne Quelle" would create a phantom error the user can
// not resolve. They are auto-set by adapters / system defaults.
const node = _makeNode('n1', 'trustee.extractFromFiles', {});
const nodeType = _makeNodeType('trustee.extractFromFiles', 'AiResult', [
{ name: 'prompt', type: 'str', required: true },
{ name: 'systemContext', type: 'str', required: true, frontendType: 'hidden' },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(1);
expect(errs[0]!.paramName).toBe('prompt');
});
});
describe('findGraphErrors', () => {
it('aggregates per-node errors and omits clean nodes', () => {
const cleanNodeType = _makeNodeType('clean.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
]);
const dirtyNodeType = _makeNodeType('dirty.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
{ name: 'p2', type: 'str', required: true },
]);
const nodes: CanvasNode[] = [
_makeNode('clean', 'clean.node', { p1: 'value' }),
_makeNode('dirty', 'dirty.node', { p1: 'set' }),
];
const result = findGraphErrors(nodes, [cleanNodeType, dirtyNodeType]);
expect(Object.keys(result)).toEqual(['dirty']);
expect(result['dirty']!.map((e) => e.paramName)).toEqual(['p2']);
});
});
// ---------------------------------------------------------------------------
// findSourceCandidates — T5/T6/T7/T8 core
// ---------------------------------------------------------------------------
describe('findSourceCandidates', () => {
function _makeFixture() {
const upstreamType = _makeNodeType('sharepoint.readDocs', 'DocumentList');
const consumerType = _makeNodeType('ai.summarize', 'AiResult', [
{ name: 'documentList', type: 'DocumentList', required: true },
]);
const upstream = _makeNode('up', 'sharepoint.readDocs');
const consumer = _makeNode('cons', 'ai.summarize');
const conns = [_makeConnection('c1', 'up', 'cons')];
return { nodes: [upstream, consumer], connections: conns, nodeTypes: [upstreamType, consumerType] };
}
it('returns the whole-output candidate first (path=[]) for the upstream', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'DocumentList',
...f,
portTypeCatalog: _portCatalog,
});
const wholeOutput = candidates.find((c) => c.nodeId === 'up' && c.path.length === 0);
expect(wholeOutput).toBeDefined();
expect(wholeOutput!.type).toBe('DocumentList');
expect(wholeOutput!.compat).toBe('ok');
});
it('drills into List[X] elements via wildcard "*" segment (T8 generic drill-down)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'str',
...f,
portTypeCatalog: _portCatalog,
});
const wildcardCandidate = candidates.find(
(c) =>
c.nodeId === 'up' &&
c.path[0] === 'documents' &&
c.path[1] === '*' &&
c.path[2] === 'name',
);
expect(wildcardCandidate).toBeDefined();
expect(wildcardCandidate!.type).toBe('str');
expect(wildcardCandidate!.compat).toBe('ok');
});
it('marks List[X] → X as iterable (T6 "iterieren"-Vorschlag)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'UdmDocument',
...f,
portTypeCatalog: _portCatalog,
});
const iterable = candidates.find(
(c) => c.nodeId === 'up' && c.path.length === 1 && c.path[0] === 'documents' && c.iterable,
);
expect(iterable).toBeDefined();
expect(iterable!.type).toBe('List[UdmDocument]');
});
it('returns no candidates when no upstream is connected (T5: 0-case)', () => {
const f = _makeFixture();
const isolated = _makeNode('iso', 'ai.summarize');
const candidates = findSourceCandidates({
consumerNodeId: 'iso',
expectedType: 'DocumentList',
nodes: [...f.nodes, isolated],
connections: f.connections,
nodeTypes: f.nodeTypes,
portTypeCatalog: _portCatalog,
});
expect(candidates).toEqual([]);
});
it('returns plain candidates (compat="ok") when expectedType is omitted', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
...f,
portTypeCatalog: _portCatalog,
});
expect(candidates.length).toBeGreaterThan(0);
expect(candidates.every((c) => c.compat === 'ok')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// strictlyCompatible — T7 strict type filter
// ---------------------------------------------------------------------------
describe('strictlyCompatible', () => {
it('keeps only ok / coerce / iterable candidates and drops mismatch', () => {
const all: SourceCandidate[] = [
{ nodeId: 'a', path: [], type: 'DocumentList', compat: 'ok' },
{ nodeId: 'a', path: ['documents'], type: 'List[UdmDocument]', compat: 'mismatch', iterable: true },
{ nodeId: 'a', path: ['count'], type: 'int', compat: 'coerce' },
{ nodeId: 'a', path: ['junk'], type: 'object', compat: 'mismatch' },
];
const out = strictlyCompatible(all);
expect(out.map((c) => c.path)).toEqual([[], ['documents'], ['count']]);
});
});

View file

@ -1,55 +0,0 @@
/**
* Lexical scope for DataPicker: ancestor node ids reachable backward on the graph.
*/
export interface GraphEdgeLike {
source: string;
target: string;
}
export interface GraphNodeLike {
id: string;
type?: string;
}
/** All node ids that can reach targetNodeId via incoming edges (excluding target). */
export function computeAncestorNodeIds(
_nodes: GraphNodeLike[],
connections: GraphEdgeLike[],
targetNodeId: string
): Set<string> {
const preds = new Map<string, Set<string>>();
for (const c of connections) {
const src = c.source;
const tgt = c.target;
if (!src || !tgt) continue;
if (!preds.has(tgt)) preds.set(tgt, new Set());
preds.get(tgt)!.add(src);
}
const seen = new Set<string>();
const stack = [targetNodeId];
while (stack.length) {
const cur = stack.pop()!;
const ps = preds.get(cur);
if (!ps) continue;
for (const p of ps) {
if (!seen.has(p)) {
seen.add(p);
stack.push(p);
}
}
}
seen.delete(targetNodeId);
return seen;
}
/** Node ids of flow.loop ancestors (subset of ancestors). */
export function findLoopAncestorIds(
nodes: GraphNodeLike[],
connections: GraphEdgeLike[],
targetNodeId: string
): string[] {
const anc = computeAncestorNodeIds(nodes, connections, targetNodeId);
const byId = new Map(nodes.map((n) => [n.id, n]));
return [...anc].filter((id) => byId.get(id)?.type === 'flow.loop');
}

View file

@ -8,6 +8,12 @@ import type { FormField } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from '../form/formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -21,7 +27,9 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
const name = String(o.name ?? `field${i + 1}`); const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `${t('Feld')} ${i + 1}`); const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text'; const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
return { name, label, type } as FormField; const required = Boolean(o.required);
const options = formFieldTypeHasConfigurableOptions(type) ? normalizeFormFieldOptions(o.options) : undefined;
return { name, label, type, required, ...(options !== undefined ? { options } : {}) } as FormField;
} }
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const }; return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
}); });
@ -43,29 +51,19 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<div className={styles.startNodeDoc}> <div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}> <p className={styles.startNodeDocIntro}>
<strong>{t('Formular-Felder')}</strong>{' '} <strong>{t('Formular-Felder')}</strong>{' '}
{t('werden beim Start ausgefüllt und liegen unter')}{' '} {t('werden beim Start ausgefüllt. Der Payload-Schlüssel wird aus der Beschriftung abgeleitet.')}
<code>payload.&lt;name&gt;</code> {t('in der Start-Ausgabe.')}
</p> </p>
<div className={styles.formFieldsList}> <div className={styles.formFieldsList}>
{fields.map((f, idx) => ( {fields.map((f, idx) => (
<div key={idx} className={styles.formFieldRow}> <div key={idx} className={styles.formFieldRow}>
<input
className={styles.startsInput}
placeholder={t('Name (Payload-Key)')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, name: e.target.value };
setFields(next);
}}
/>
<input <input
className={styles.startsInput} className={styles.startsInput}
placeholder={t('Beschriftung')} placeholder={t('Beschriftung')}
value={f.label ?? ''} value={f.label ?? ''}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const next = [...fields]; const next = [...fields];
next[idx] = { ...f, label: e.target.value }; next[idx] = { ...f, label, name: deriveFormFieldPayloadKey(label, idx) };
setFields(next); setFields(next);
}} }}
/> />
@ -74,7 +72,12 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
value={f.type ?? 'text'} value={f.type ?? 'text'}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] }; const type = e.target.value as FormField['type'];
const row: FormField = { ...f, type };
if (formFieldTypeHasConfigurableOptions(type)) {
row.options = normalizeFormFieldOptions(row.options);
}
next[idx] = row;
setFields(next); setFields(next);
}} }}
> >
@ -89,13 +92,32 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
> >
</button> </button>
{formFieldTypeHasConfigurableOptions(f.type) ? (
<div className={styles.formFieldOptionsBlock}>
<FormFieldOptionsEditor
options={normalizeFormFieldOptions(f.options)}
onChange={(opts) => {
const next = [...fields];
next[idx] = { ...next[idx], options: opts };
setFields(next);
}}
/>
</div>
) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
className={styles.startsAddBtn} className={styles.startsAddBtn}
onClick={() => onClick={() =>
setFields([...fields, { name: `field${fields.length + 1}`, label: t('Neues Feld'), type: 'text' }]) setFields([
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
label: '',
type: 'text',
},
])
} }
> >
{t('+ Feld')} {t('+ Feld')}

View file

@ -1,438 +1,33 @@
/** /**
* Start node (Zeitplan) Karten-UI mit Konfiguration unter der gewählten Option. * Start node (Zeitplan) Accordion planner; gespeichert werden cron + schedule.
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useCallback, useMemo } from 'react';
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion'; import { flushSync } from 'react-dom';
import type { NodeConfigRendererProps } from '../shared/types'; import type { NodeConfigRendererProps } from '../shared/types';
import { SchedulePlanner } from '../../../SchedulePlanner';
import { import {
type ScheduleSpec,
type ScheduleMode,
type IntervalUnit,
type CalendarPeriod,
buildCronFromSpec, buildCronFromSpec,
scheduleSpecFromParams, scheduleSpecFromParams,
WEEKDAYS_MO_SO, scheduleSpecToPersistentJson,
} from '../runtime/scheduleCron'; type ScheduleSpec,
import styles from '../../editor/Automation2FlowEditor.module.css'; } from '../../../../utils/scheduleCron';
import { useLanguage } from '../../../../providers/language/LanguageContext';
function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
return [
{ value: 'daily', title: t('Täglich'), subtitle: t('Jeden Tag zur gleichen Zeit') },
{ value: 'weekdays', title: t('Werktage'), subtitle: t('Montag bis Freitag') },
{ value: 'weekly', title: t('Bestimmte Tage'), subtitle: t('Wochentage auswählen') },
{ value: 'calendar', title: t('Ein anderer Zeitraum'), subtitle: t('Monatlich oder jährlich') },
{ value: 'interval', title: t('Intervall'), subtitle: t('In regelmäßigen Abständen') },
];
}
function _monthNames(t: (k: string) => string): string[] {
return [
t('Januar'), t('Februar'), t('März'), t('April'),
t('Mai'), t('Juni'), t('Juli'), t('August'),
t('September'), t('Oktober'), t('November'), t('Dezember'),
];
}
function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] {
return [
{ value: 'seconds', label: t('Sekunde'), title: t('Sekunden') },
{ value: 'minutes', label: t('Minute'), title: t('Minuten') },
{ value: 'hours', label: t('Stunde'), title: t('Stunden') },
{ value: 'days', label: t('Tag'), title: t('Tage') },
{ value: 'years', label: t('Jahr'), title: t('Jahre') },
];
}
function timeString(hour: number, minute: number): string {
return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`;
}
function commitSpec(next: ScheduleSpec, updateParam: (key: string, value: unknown) => void) {
updateParam('schedule', next);
updateParam('cron', buildCronFromSpec(next));
}
function clampInterval(value: number, unit: IntervalUnit): number {
const v = Math.max(1, Math.floor(value) || 1);
switch (unit) {
case 'seconds':
return Math.min(59, v);
case 'minutes':
return Math.min(59, v);
case 'hours':
return Math.min(23, v);
case 'days':
return Math.min(31, v);
case 'years':
return Math.min(99, v);
default:
return v;
}
}
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage(); const spec = useMemo(
const modeOptions = _getModeOptions(t); () => scheduleSpecFromParams(params as Record<string, unknown>),
const intervalUnits = _getIntervalUnits(t); [params.cron, params.schedule]
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params)); );
const prefersReducedMotion = useReducedMotion();
const specModeRef = useRef(spec.mode);
specModeRef.current = spec.mode;
useEffect(() => {
const derived = scheduleSpecFromParams(params as Record<string, unknown>);
console.log('[ScheduleStartNode] useEffect params → setSpec', {
paramsCron: params.cron,
paramsSchedule: params.schedule,
derivedMode: derived.mode,
previousSpecMode: specModeRef.current,
});
setSpec(derived);
}, [params.cron, params.schedule]);
useEffect(() => {
console.log('[ScheduleStartNode] spec.mode changed (UI sollte passen)', {
specMode: spec.mode,
cssBlockBase: styles.scheduleModeBlock,
cssBlockActive: styles.scheduleModeBlockActive,
});
}, [spec.mode]);
const push = useCallback( const push = useCallback(
(next: ScheduleSpec) => { (next: ScheduleSpec) => {
setSpec(next); const sched = scheduleSpecToPersistentJson(next);
commitSpec(next, updateParam); const cron = buildCronFromSpec(next);
flushSync(() => {
updateParam('schedule', sched);
});
updateParam('cron', cron);
}, },
[updateParam] [updateParam]
); );
return <SchedulePlanner value={spec} onChange={push} />;
const setMode = (mode: ScheduleMode) => {
console.log('[ScheduleStartNode] setMode', {
from: spec.mode,
to: mode,
refMode: specModeRef.current,
});
const base: ScheduleSpec = { ...spec, mode };
if (mode === 'weekly' && base.weekdays.length === 0) {
base.weekdays = [1, 2, 3, 4, 5];
}
if (mode === 'calendar') {
base.calendarPeriod = base.calendarPeriod ?? 'monthly';
}
push(base);
};
const onModeCardPointerEvent = (
phase: 'pointerdown' | 'click',
e: React.PointerEvent | React.MouseEvent,
o: { value: ScheduleMode; title: string; subtitle: string }
) => {
const el = e.target as HTMLElement;
const cur = e.currentTarget as HTMLElement;
const isLast = o.value === 'interval';
if (!isLast) return;
const cx = 'clientX' in e ? e.clientX : 0;
const cy = 'clientY' in e ? e.clientY : 0;
const hit = typeof document !== 'undefined' ? document.elementFromPoint(cx, cy) : null;
const hitH = hit as HTMLElement | null;
console.log(`[ScheduleStartNode] ${phase} — unterstes Element (Intervall)`, {
intendedMode: o.value,
title: o.title,
specModeClosure: spec.mode,
specModeRef: specModeRef.current,
eventPhase: e.nativeEvent.eventPhase,
target: { tag: el?.tagName, className: el?.className, id: el?.id },
currentTarget: { tag: cur?.tagName, className: cur?.className },
clientX: cx,
clientY: cy,
pointerId: 'pointerId' in e ? (e as React.PointerEvent).pointerId : undefined,
isTrusted: e.nativeEvent.isTrusted,
elementFromPoint: hitH
? {
tag: hitH.tagName,
className: hitH.className,
dataScheduleMode: hitH.closest?.('[data-schedule-mode]')?.getAttribute('data-schedule-mode'),
textSlice: (hitH.textContent ?? '').slice(0, 60),
}
: null,
});
};
const onTimeChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const v = ev.target.value;
if (!v) return;
const [hs, ms] = v.split(':');
const hour = Number(hs);
const minute = Number(ms);
if (Number.isNaN(hour) || Number.isNaN(minute)) return;
push({ ...spec, hour, minute });
};
const toggleWeekday = (cronDow: number) => {
const set = new Set(spec.weekdays);
if (set.has(cronDow)) set.delete(cronDow);
else set.add(cronDow);
let weekdays = [...set];
if (weekdays.length === 0) weekdays = [cronDow];
push({ ...spec, weekdays });
};
const setCalendarPeriod = (calendarPeriod: CalendarPeriod) => {
push({ ...spec, calendarPeriod });
};
const setIntervalUnit = (intervalUnit: IntervalUnit) => {
const next = { ...spec, intervalUnit };
next.intervalValue = clampInterval(next.intervalValue, intervalUnit);
push(next);
};
const panelTransition = prefersReducedMotion
? { duration: 0 }
: {
height: { duration: 0.44, ease: EASE_SMOOTH },
opacity: { duration: 0.3, ease: 'easeOut' as const },
};
return (
<div className={styles.schedulePanel}>
<p className={styles.startNodeDocIntro}>
{t(
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
)}
</p>
<LayoutGroup>
<div className={styles.scheduleModeStack}>
{modeOptions.map((o) => (
<motion.div
key={o.value}
data-schedule-mode={o.value}
data-active={spec.mode === o.value ? 'true' : 'false'}
className={
spec.mode === o.value
? `${styles.scheduleModeBlock} ${styles.scheduleModeBlockActive}`
: styles.scheduleModeBlock
}
>
<motion.button
type="button"
className={styles.scheduleModeCard}
onPointerDown={(e) => onModeCardPointerEvent('pointerdown', e, o)}
onClick={(e) => {
onModeCardPointerEvent('click', e, o);
setMode(o.value);
}}
whileTap={prefersReducedMotion ? undefined : { scale: 0.992 }}
transition={{ type: 'spring', stiffness: 520, damping: 32 }}
>
<span className={styles.scheduleModeCardTitle}>{o.title}</span>
<span className={styles.scheduleModeCardSubtitle}>{o.subtitle}</span>
</motion.button>
<AnimatePresence initial={false}>
{spec.mode === o.value && (
<motion.div
key={`panel-${o.value}`}
className={styles.scheduleModeConfigShell}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={panelTransition}
style={{ overflow: 'hidden' }}
>
<div className={styles.scheduleModeConfig}>
{o.value === 'daily' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
)}
{o.value === 'weekdays' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
)}
{o.value === 'weekly' && (
<>
<div className={styles.scheduleFieldCol}>
<span className={styles.scheduleFieldLabel}>{t('Wochentage')}</span>
<div className={styles.scheduleWeekdayToggles}>
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
<button
key={cronDow}
type="button"
className={
spec.weekdays.includes(cronDow) ? styles.scheduleDayOn : styles.scheduleDayOff
}
onClick={() => toggleWeekday(cronDow)}
>
{cronDow === 1 ? t('Mo') : cronDow === 2 ? t('Di') : cronDow === 3 ? t('Mi') : cronDow === 4 ? t('Do') : cronDow === 5 ? t('Fr') : cronDow === 6 ? t('Sa') : t('So')}
</button>
))}
</div>
</div>
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
</>
)}
{o.value === 'calendar' && (
<>
<div className={styles.scheduleSubModes}>
<button
type="button"
className={
spec.calendarPeriod === 'monthly'
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
: styles.scheduleSubModeBtn
}
onClick={() => setCalendarPeriod('monthly')}
>
{t('Monatlich')}
</button>
<button
type="button"
className={
spec.calendarPeriod === 'yearly'
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
: styles.scheduleSubModeBtn
}
onClick={() => setCalendarPeriod('yearly')}
>
{t('Jährlich')}
</button>
</div>
{spec.calendarPeriod === 'monthly' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Monatstag')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
)}
{spec.calendarPeriod === 'yearly' && (
<div className={styles.scheduleYearlyRow}>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>{t('Monat')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthIndex}
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
>
{_monthNames(t).map((name, i) => (
<option key={i + 1} value={i + 1}>
{name}
</option>
))}
</select>
</label>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>{t('Tag')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
</div>
)}
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
</>
)}
{o.value === 'interval' && (
<div className={styles.scheduleIntervalRow}>
<span className={styles.scheduleFieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
className={styles.scheduleNumberInput}
value={spec.intervalValue}
onChange={(e) =>
push({
...spec,
intervalValue: clampInterval(Number(e.target.value) || 1, spec.intervalUnit),
})
}
/>
<select
className={styles.scheduleUnitSelect}
value={spec.intervalUnit}
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
title={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
>
{intervalUnits.map((u) => (
<option key={u.value} value={u.value} title={u.title}>
{u.label}
</option>
))}
</select>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</LayoutGroup>
</div>
);
}; };

View file

@ -1,250 +0,0 @@
/**
* Switch node config - RefSourceSelect für Datenquelle, Fälle mit Operator + Wert.
* Gleicher Kontext wie IfElse: typabhängige Operatoren (z.B. Alter < 19, = 30).
*/
import React from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, createValue } 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 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 };
}
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const valueParam = params.value;
const ref = isRef(valueParam) ? valueParam : null;
let staticValue: string | number = '';
if (!ref && valueParam != null) {
if (typeof valueParam === 'object' && 'value' in valueParam) {
const v = (valueParam as { value: unknown }).value;
staticValue = v !== undefined && v !== null ? String(v) : '';
} else if (typeof valueParam === 'string' || typeof valueParam === 'number') {
staticValue = valueParam;
}
}
const rawCases = (params.cases as unknown[]) ?? [];
const cases: SwitchCase[] = rawCases.map(normalizeCase);
const fieldType = dataFlow ? getFieldType(ref, dataFlow.nodes, dataFlow.nodeOutputsPreview) : 'unknown';
const operators = operatorsForType(fieldType);
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 setValue = (val: unknown) => {
updateParam('value', val);
};
const setCases = (next: SwitchCase[]) => {
updateParam('cases', next);
};
const handleRefChange = (newRef: { type: 'ref'; nodeId: string; path: (string | number)[] } | null) => {
if (newRef) {
setValue(newRef);
} else {
setValue(createValue(staticValue));
}
};
const handleStaticValueChange = (v: string) => {
setValue(createValue(fieldType === 'number' ? parseFloat(v) || 0 : v));
};
const handleCaseOperatorChange = (index: number, op: string) => {
const opDef = operators.find((o) => o.value === op);
const next = [...cases];
next[index] = {
operator: op,
value: opDef?.needsValue ? cases[index]?.value : undefined,
};
setCases(next);
};
const handleCaseValueChange = (index: number, v: string | number | boolean) => {
const next = [...cases];
next[index] = {
...next[index],
value: fieldType === 'number' ? (typeof v === 'number' ? v : parseFloat(String(v)) || 0)
: fieldType === 'boolean' ? (v === true || v === 'true')
: String(v),
};
setCases(next);
};
const renderCaseValueInput = (caseItem: SwitchCase, index: number) => {
const val = caseItem.value;
const valStr = String(val ?? '');
if (mimeTypeOptions.length > 0) {
return (
<select
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
className={styles.startsInput}
>
<option value="">{t('MIME-Typ wählen')}</option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
</option>
))}
</select>
);
}
if (fieldType === 'number') {
return (
<input
type="number"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, parseFloat(e.target.value) || 0)}
placeholder="0"
/>
);
}
if (fieldType === 'date') {
return (
<input
type="date"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
/>
);
}
if (fieldType === 'boolean') {
return (
<select
value={val === true ? 'true' : val === false ? 'false' : ''}
onChange={(e) => {
const v = e.target.value;
handleCaseValueChange(index, v === 'true' ? true : v === 'false' ? false : '');
}}
className={styles.startsInput}
>
<option value="">{t('Wählen')}</option>
<option value="true">{t('Ja (true)')}</option>
<option value="false">{t('Nein (false)')}</option>
</select>
);
}
return (
<input
type="text"
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
placeholder={isMimeTypeRef ? t('z.B. application/pdf') : t('Wert')}
/>
);
};
const addCase = () => {
const opDef = operators[0];
const defaultVal = opDef?.needsValue
? (fieldType === 'number' ? 0 : fieldType === 'boolean' ? false : '')
: undefined;
setCases([
...cases,
{ operator: opDef?.value ?? 'eq', value: defaultVal },
]);
};
return (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>{t('Datenquelle')}</label>
<RefSourceSelect
value={ref}
onChange={handleRefChange}
placeholder={t('Feld zum Vergleich wählen')}
/>
</div>
{!ref && (
<div className={styles.ifElseConditionRow}>
<label>{t('Fester Wert (ohne Referenz)')}</label>
<input
type="text"
value={String(staticValue ?? '')}
onChange={(e) => handleStaticValueChange(e.target.value)}
placeholder={t('z. B. CH oder 42')}
/>
</div>
)}
<div className={styles.ifElseConditionRow}>
<label>{t('Fälle / Reihenfolge / Ausgabe')}</label>
<div className={styles.formFieldsList}>
{cases.map((c, i) => {
const opDef = operators.find((o) => o.value === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} className={styles.formFieldRow}>
<select
value={c.operator}
onChange={(e) => handleCaseOperatorChange(i, e.target.value)}
className={styles.startsInput}
style={{ minWidth: 140 }}
>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{needsValue && (
<div style={{ flex: 1 }}>
{renderCaseValueInput(c, i)}
</div>
)}
<button
type="button"
className={styles.formFieldRemoveButton}
onClick={() => setCases(cases.filter((_, j) => j !== i))}
>
</button>
</div>
);
})}
<button type="button" className={styles.startsAddBtn} onClick={addCase}>
{t('+ Fall')}
</button>
</div>
</div>
</div>
);
};

View file

@ -1 +1,2 @@
export { SwitchNodeConfig } from './SwitchNodeConfig'; export { CaseListEditor as SwitchNodeConfig } from '../frontendTypeRenderers/CaseListEditor';
export type { SwitchCase } from '../frontendTypeRenderers/CaseListEditor';

View file

@ -0,0 +1,236 @@
/**
* Runtime form fields shared by Human Task input.form and workflow list trigger.form start.
* Field rows match task.config.fields / graph node parameters.formFields shape from the backend.
*/
import React, { useEffect, useState } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import { loadClickupListTasksForDropdown, type ApiRequestFunction } from '../../../api/workflowApi';
import { normalizeFormFieldOptions } from '../nodes/form';
import { useLanguage } from '../../../providers/language/LanguageContext';
export type WorkflowRuntimeFormFieldRow = {
name: string;
type: string;
label: string;
required?: boolean;
options?: unknown;
clickupConnectionId?: string;
clickupListId?: string;
clickupStatusOptions?: Array<{ value: string; label: string }>;
};
export function relationshipTaskIdFromFormValue(v: unknown): string {
if (v && typeof v === 'object' && !Array.isArray(v) && 'add' in v) {
const a = (v as { add?: unknown[] }).add;
if (Array.isArray(a) && a[0] != null && String(a[0]).trim()) return String(a[0]);
}
return '';
}
function InputFormClickupTaskField({
connectionId,
listId,
value,
onChange,
request,
}: {
connectionId: string;
listId: string;
value: unknown;
onChange: (v: unknown) => void;
request: ApiRequestFunction;
}) {
const { t } = useLanguage();
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const cid = connectionId.trim();
const lid = listId.trim();
if (!cid || !lid) {
setTasks([]);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
loadClickupListTasksForDropdown(request, cid, lid)
.then((rows) => {
if (!cancelled) setTasks(rows);
})
.catch(() => {
if (!cancelled) {
setTasks([]);
setErr(t('Aufgaben konnten nicht geladen werden.'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [request, connectionId, listId, t]);
const sel = relationshipTaskIdFromFormValue(value);
if (!connectionId.trim() || !listId.trim()) {
return (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
{t('Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt — bitte Workflow prüfen.')}
</p>
);
}
return (
<>
{err ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>{t('lade Aufgaben')}</p>
) : (
<select
value={sel}
onChange={(e) => {
const tid = e.target.value;
if (!tid) onChange({ add: [], rem: [] });
else onChange({ add: [tid], rem: [] });
}}
>
<option value="">{t('Aufgabe wählen')}</option>
{tasks.map((taskRow) => (
<option key={taskRow.id} value={taskRow.id}>
{taskRow.name}
</option>
))}
</select>
)}
</>
);
}
export function useWorkflowRuntimeFormRequiredOk(
fields: WorkflowRuntimeFormFieldRow[],
formData: Record<string, unknown>
): boolean {
const requiredFields = fields.filter((f) => f.required);
return requiredFields.every((f) => {
const v = formData[f.name];
if (f.type === 'boolean') return true;
if (f.type === 'clickup_tasks') {
return relationshipTaskIdFromFormValue(v) !== '';
}
if (f.type === 'clickup_status') {
return v !== undefined && v !== null && String(v).trim() !== '';
}
if (
(f.type === 'select' || f.type === 'enum') &&
normalizeFormFieldOptions(f.options).some((o) => String(o.value).trim() !== '')
) {
return v !== undefined && v !== null && String(v).trim() !== '';
}
return v !== undefined && v !== null && String(v).trim() !== '';
});
}
export interface WorkflowRuntimeFormFieldsProps {
fields: WorkflowRuntimeFormFieldRow[];
formData: Record<string, unknown>;
setFormData: React.Dispatch<React.SetStateAction<Record<string, unknown>>>;
formFieldsClassName: string;
}
/**
* Renders the same controls as TaskCard input.form (no Popup parent wraps if needed).
*/
export const WorkflowRuntimeFormFields: React.FC<WorkflowRuntimeFormFieldsProps> = ({
fields,
formData,
setFormData,
formFieldsClassName,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const renderFormControl = (field: WorkflowRuntimeFormFieldRow): React.ReactNode => {
const selectChoices = normalizeFormFieldOptions(field.options).filter((o) => String(o.value).trim() !== '');
if (field.type === 'boolean') {
return (
<input
type="checkbox"
checked={(formData[field.name] as boolean) ?? false}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.checked }))}
/>
);
}
if (field.type === 'clickup_tasks' && request) {
return (
<InputFormClickupTaskField
connectionId={field.clickupConnectionId ?? ''}
listId={field.clickupListId ?? ''}
value={formData[field.name]}
onChange={(v) => setFormData((p) => ({ ...p, [field.name]: v }))}
request={request}
/>
);
}
if (
field.type === 'clickup_status' &&
Array.isArray(field.clickupStatusOptions) &&
field.clickupStatusOptions.length > 0
) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
>
<option value="">{t('Status wählen')}</option>
{field.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
>
<option value="">{t('Bitte wählen')}</option>
{selectChoices.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select>
);
}
return (
<input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={(formData[field.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [field.name]: e.target.value }))}
/>
);
};
return (
<div className={formFieldsClassName}>
{fields.map((f) => (
<div key={f.name}>
<label>
{f.label || f.name}
{f.required && ' *'}
</label>
{renderFormControl(f)}
</div>
))}
</div>
);
};

View file

@ -543,6 +543,22 @@
line-height: 1.5; line-height: 1.5;
} }
/* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */
.embeddedPicker {
display: flex;
flex-direction: column;
flex: none !important;
min-height: 0;
overflow: hidden;
/* height + maxHeight set inline (embedMaxHeight) */
}
.embeddedPicker .treeWrapper {
flex: 1 1 0;
min-height: 0;
max-height: none;
}
/* Compact mode */ /* Compact mode */
.compactMode .sectionHeader { .compactMode .sectionHeader {
padding: 6px 8px; padding: 6px 8px;

View file

@ -104,6 +104,7 @@ function _buildChildMap<T>(nodes: TreeNode<T>[]): Map<string | '__root__', TreeN
function _flatten<T>( function _flatten<T>(
nodes: TreeNode<T>[], nodes: TreeNode<T>[],
expandedIds: Set<string>, expandedIds: Set<string>,
confirmedEmptyFolderIds: Set<string>,
): FlatEntry<T>[] { ): FlatEntry<T>[] {
const childMap = _buildChildMap(nodes); const childMap = _buildChildMap(nodes);
const result: FlatEntry<T>[] = []; const result: FlatEntry<T>[] = [];
@ -112,8 +113,22 @@ function _flatten<T>(
const children = childMap.get(parentKey); const children = childMap.get(parentKey);
if (!children) return; if (!children) return;
for (const node of children) { for (const node of children) {
const nodeChildren = childMap.get(node.id); const loadedChildren = childMap.get(node.id) ?? [];
const hasChildren = (nodeChildren && nodeChildren.length > 0) || node.type === 'folder'; const hasLoadedKids = loadedChildren.length > 0;
let hasChildren = false;
if (node.type !== 'folder') {
hasChildren = hasLoadedKids;
} else if (hasLoadedKids) {
hasChildren = true;
} else if (confirmedEmptyFolderIds.has(node.id)) {
hasChildren = false;
} else if (node.hasSubfoldersInApiTree === false && node.mayHaveLazyFileChildren === false) {
hasChildren = false;
} else {
hasChildren = true;
}
result.push({ node, depth, hasChildren }); result.push({ node, depth, hasChildren });
if (hasChildren && expandedIds.has(node.id)) { if (hasChildren && expandedIds.has(node.id)) {
_walk(node.id, depth + 1); _walk(node.id, depth + 1);
@ -180,6 +195,8 @@ interface TreeNodeRowProps<T = any> {
onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void; onDragOver: (e: React.DragEvent, node: TreeNode<T>) => void;
onDragLeave: (e: React.DragEvent) => void; onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent, node: TreeNode<T>) => void; onDrop: (e: React.DragEvent, node: TreeNode<T>) => void;
hideRowActionButtons?: boolean;
dragDropEnabled?: boolean;
} }
const TreeNodeRow = React.memo(function TreeNodeRow<T>({ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
@ -213,6 +230,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDrop, onDrop,
hideRowActionButtons = false,
dragDropEnabled = true,
}: TreeNodeRowProps<T>) { }: TreeNodeRowProps<T>) {
const { node, depth, hasChildren } = entry; const { node, depth, hasChildren } = entry;
const renameRef = useRef<HTMLInputElement>(null); const renameRef = useRef<HTMLInputElement>(null);
@ -246,11 +265,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
const _handleDoubleClick = useCallback( const _handleDoubleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (hideRowActionButtons) return;
if (ownership === 'own' && provider.canRename?.(node)) { if (ownership === 'own' && provider.canRename?.(node)) {
onStartRename(node.id); onStartRename(node.id);
} }
}, },
[ownership, provider, node, onStartRename], [hideRowActionButtons, ownership, provider, node, onStartRename],
); );
const _handleRowClick = useCallback( const _handleRowClick = useCallback(
@ -298,11 +318,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
className={rowClasses} className={rowClasses}
onClick={_handleRowClick} onClick={_handleRowClick}
onDoubleClick={_handleDoubleClick} onDoubleClick={_handleDoubleClick}
draggable draggable={dragDropEnabled}
onDragStart={(e) => onDragStart(e, node)} onDragStart={dragDropEnabled ? (e) => onDragStart(e, node) : undefined}
onDragOver={(e) => onDragOver(e, node)} onDragOver={dragDropEnabled ? (e) => onDragOver(e, node) : undefined}
onDragLeave={onDragLeave} onDragLeave={dragDropEnabled ? onDragLeave : undefined}
onDrop={(e) => onDrop(e, node)} onDrop={dragDropEnabled ? (e) => onDrop(e, node) : undefined}
data-node-id={node.id} data-node-id={node.id}
title={node.name} title={node.name}
role="treeitem" role="treeitem"
@ -312,7 +332,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
> >
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} /> <div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
{selectable && ( {selectable && !hideRowActionButtons && (
<input <input
type="checkbox" type="checkbox"
className={styles.nodeCheckbox} className={styles.nodeCheckbox}
@ -361,156 +381,174 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</span> </span>
)} )}
<div className={styles.nodeSizeGroup}> {!hideRowActionButtons && (
<span className={styles.nodeSize}> <span className={styles.nodeSize}>
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''} {node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
</span> </span>
)}
<div className={styles.nodeActionsHover}> {!hideRowActionButtons && (
{canCreateChild && onCreateChild && ( <>
<button <div className={styles.nodeActionsHover}>
className={styles.emojiBtn} {canCreateChild && onCreateChild && (
onClick={(e) => { e.stopPropagation(); onCreateChild(node.id); }} <button
title="Neuer Unterordner" className={styles.emojiBtn}
tabIndex={-1} onClick={(e) => {
> e.stopPropagation();
{'\u2795'} onCreateChild(node.id);
</button> }}
)} title="Neuer Unterordner"
tabIndex={-1}
>
{'\u2795'}
</button>
)}
{canRename && ( {canRename && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onStartRename(node.id); }} onClick={(e) => {
title="Umbenennen" e.stopPropagation();
tabIndex={-1} onStartRename(node.id);
> }}
{'\u270F\uFE0F'} title="Umbenennen"
</button> tabIndex={-1}
)} >
{'\u270F\uFE0F'}
</button>
)}
{node.type !== 'folder' && provider.downloadNode && ( {node.type !== 'folder' && provider.downloadNode && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDownload(node); }} onClick={(e) => {
title="Datei herunterladen" e.stopPropagation();
tabIndex={-1} onDownload(node);
> }}
{'\u{1F4E5}'} title="Datei herunterladen"
</button> tabIndex={-1}
)} >
{'\u{1F4E5}'}
</button>
)}
{canDelete && ( {canDelete && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { e.stopPropagation(); onDelete(node.id); }} onClick={(e) => {
title="Loeschen" e.stopPropagation();
tabIndex={-1} onDelete(node.id);
> }}
{'\u{1F5D1}\uFE0F'} title="Loeschen"
</button> tabIndex={-1}
)} >
</div> {'\u{1F5D1}\uFE0F'}
</div> </button>
)}
</div>
<div className={styles.nodeActionsPersistent}> <div className={styles.nodeActionsPersistent}>
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */} {/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
{node.extraActions?.map((action) => ( {node.extraActions?.map((action) => (
<button <button
key={action.key} key={action.key}
className={`${styles.emojiBtn} ${action.disabled ? styles.emojiBtnReadonly : ''}`} className={`${styles.emojiBtn} ${action.disabled ? styles.emojiBtnReadonly : ''}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!action.disabled) onExtraAction(node.id, action); if (!action.disabled) onExtraAction(node.id, action);
}} }}
title={action.tooltip} title={action.tooltip}
tabIndex={-1} tabIndex={-1}
disabled={action.disabled} disabled={action.disabled}
> >
{pendingActions.has(action.key) {pendingActions.has(action.key)
? <span className={styles.flagSpinner} /> ? <span className={styles.flagSpinner} />
: action.value === 'mixed' : action.value === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span> ? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: action.icon} : action.icon}
</button> </button>
))} ))}
{node.ragIndexEnabled !== undefined && ( {node.ragIndexEnabled !== undefined && (
<button <button
className={`${styles.emojiBtn} ${canPatchRagIndex ? '' : styles.emojiBtnReadonly}`} className={`${styles.emojiBtn} ${canPatchRagIndex ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (canPatchRagIndex) onToggleRagIndex(node); if (canPatchRagIndex) onToggleRagIndex(node);
}} }}
title={node.ragIndexEnabled === 'mixed' title={node.ragIndexEnabled === 'mixed'
? 'Gemischt - Klick setzt explizit' ? 'Gemischt - Klick setzt explizit'
: node.ragIndexEnabled ? 'RAG-Indexierung an' : 'RAG-Indexierung aus'} : node.ragIndexEnabled ? 'RAG-Indexierung an' : 'RAG-Indexierung aus'}
tabIndex={-1} tabIndex={-1}
> >
{pendingActions.has(_ACTION_RAG) {pendingActions.has(_ACTION_RAG)
? <span className={styles.flagSpinner} /> ? <span className={styles.flagSpinner} />
: node.ragIndexEnabled === 'mixed' : node.ragIndexEnabled === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span> ? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.ragIndexEnabled === true : node.ragIndexEnabled === true
? _RAG_ON_EMOJI ? _RAG_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_RAG_OFF_EMOJI}</span>} : <span style={_OFF_STATE_STYLE}>{_RAG_OFF_EMOJI}</span>}
</button> </button>
)} )}
{onSendToChat && ( {onSendToChat && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSendToChat(node); onSendToChat(node);
}} }}
title="In Chat senden" title="In Chat senden"
tabIndex={-1} tabIndex={-1}
> >
{'\u{1F4AC}'} {'\u{1F4AC}'}
</button> </button>
)} )}
{node.scope !== undefined && ( {node.scope !== undefined && (
<button <button
className={`${styles.emojiBtn} ${canPatchScope ? '' : styles.emojiBtnReadonly}`} className={`${styles.emojiBtn} ${canPatchScope ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (canPatchScope) onCycleScope(node); if (canPatchScope) onCycleScope(node);
}} }}
title={node.scope === 'mixed' ? 'Gemischt - Klick setzt explizit' : `Scope: ${node.scope}`} title={node.scope === 'mixed'
tabIndex={-1} ? 'Gemischt - Klick setzt explizit'
> : `Scope: ${node.scope}`}
{pendingActions.has(_ACTION_SCOPE) tabIndex={-1}
? <span className={styles.flagSpinner} /> >
: node.scope === 'mixed' {pendingActions.has(_ACTION_SCOPE)
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span> ? <span className={styles.flagSpinner} />
: (_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal)} : node.scope === 'mixed'
</button> ? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
)} : (_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal)}
</button>
)}
{node.neutralize !== undefined && ( {node.neutralize !== undefined && (
<button <button
className={`${styles.emojiBtn} ${canPatchNeutralize ? '' : styles.emojiBtnReadonly}`} className={`${styles.emojiBtn} ${canPatchNeutralize ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (canPatchNeutralize) onToggleNeutralize(node); if (canPatchNeutralize) onToggleNeutralize(node);
}} }}
title={node.neutralize === 'mixed' title={node.neutralize === 'mixed'
? 'Gemischt - Klick setzt explizit' ? 'Gemischt - Klick setzt explizit'
: node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'} : node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
tabIndex={-1} tabIndex={-1}
> >
{pendingActions.has(_ACTION_NEUTRALIZE) {pendingActions.has(_ACTION_NEUTRALIZE)
? <span className={styles.flagSpinner} /> ? <span className={styles.flagSpinner} />
: node.neutralize === 'mixed' : node.neutralize === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span> ? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.neutralize === true : node.neutralize === true
? _NEUTRALIZE_ON_EMOJI ? _NEUTRALIZE_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_NEUTRALIZE_OFF_EMOJI}</span>} : <span style={_OFF_STATE_STYLE}>{_NEUTRALIZE_OFF_EMOJI}</span>}
</button> </button>
)} )}
</div> </div>
</>
)}
</div> </div>
); );
}) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement; }) as <T>(props: TreeNodeRowProps<T>) => React.ReactElement;
@ -530,8 +568,11 @@ export function FormGeneratorTree<T = any>({
onSendToChat, onSendToChat,
allowCreateFolder = true, allowCreateFolder = true,
selectable = true, selectable = true,
refreshAfterAction = false,
className, className,
embedMaxHeight,
hideRowActionButtons = false,
hideSectionHeader = false,
enableDragDrop,
}: FormGeneratorTreeProps<T>) { }: FormGeneratorTreeProps<T>) {
const { t } = useLanguage(); const { t } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
@ -546,8 +587,11 @@ export function FormGeneratorTree<T = any>({
const [dragOverId, setDragOverId] = useState<string | null>(null); const [dragOverId, setDragOverId] = useState<string | null>(null);
const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set()); const [draggingIds, setDraggingIds] = useState<Set<string>>(new Set());
const [filterText, setFilterText] = useState(''); const [filterText, setFilterText] = useState('');
/** Map of nodeId -> set of action keys currently pending (for spinner rendering). */ /** Folders we expanded and confirmed have no visible children → hide chevron like a real leaf */
const [pendingActions, setPendingActions] = useState<Map<string, Set<string>>>(new Map()); const [confirmedEmptyFolderIds, setConfirmedEmptyFolderIds] = useState(() => new Set<string>());
/** Per-node set of in-flight action keys (e.g. scope/neutralize/rag) so rows
* can render a spinner over the corresponding button. */
const [pendingActions, setPendingActions] = useState<Map<string, Set<string>>>(() => new Map());
const lastSelectedIdRef = useRef<string | null>(null); const lastSelectedIdRef = useRef<string | null>(null);
const treeContentRef = useRef<HTMLDivElement>(null); const treeContentRef = useRef<HTMLDivElement>(null);
/** Tracks node ids for which auto-expand has already fired (one-shot). */ /** Tracks node ids for which auto-expand has already fired (one-shot). */
@ -569,48 +613,28 @@ export function FormGeneratorTree<T = any>({
); );
/** After a toggle, collect all currently visible node IDs and ask the /** After a toggle, refetch children for root + all expanded parents so the
* provider for their updated attributes. Patches only attribute fields * backend-authoritative effective flag values are current. No attribute-only
* (neutralize, scope, ragIndexEnabled) on existing nodes no structural * shortcut the backend is the single source of truth (spec 2026-05-18). */
* reload. Falls back to full refetch if provider doesn't implement
* refreshAttributes. */
const _refreshVisibleAttributes = useCallback(async () => { const _refreshVisibleAttributes = useCallback(async () => {
if (provider.refreshAttributes) { const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)];
const visibleIds = flatEntriesRef.current.map((e) => e.node.id); const fetched = await Promise.all(
if (visibleIds.length === 0) return; expandedList.map((p) => provider.loadChildren(p, ownership)),
const attrs = await provider.refreshAttributes(visibleIds); );
setNodes((prev) => const refetchedParents = new Set(expandedList.map((p) => p ?? '__null__'));
prev.map((n) => { setNodes((prev) => {
const update = attrs.get(n.id); const keepers = prev.filter((n) => {
if (!update) return n; const key = n.parentId ?? '__null__';
const patched: Partial<typeof n> = {}; return !refetchedParents.has(key);
if (n.neutralize !== undefined && update.neutralize !== undefined) patched.neutralize = update.neutralize;
if (n.scope !== undefined && update.scope !== undefined) patched.scope = update.scope;
if (n.ragIndexEnabled !== undefined && update.ragIndexEnabled !== undefined) patched.ragIndexEnabled = update.ragIndexEnabled;
if (Object.keys(patched).length === 0) return n;
return { ...n, ...patched };
}),
);
} else {
const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)];
const fetched = await Promise.all(
expandedList.map((p) => provider.loadChildren(p, ownership)),
);
const refetchedParents = new Set(expandedList.map((p) => p ?? '__null__'));
setNodes((prev) => {
const keepers = prev.filter((n) => {
const key = n.parentId ?? '__null__';
return !refetchedParents.has(key);
});
return [...keepers, ...fetched.flat()];
}); });
} return [...keepers, ...fetched.flat()];
});
}, [expandedIds, provider, ownership]); }, [expandedIds, provider, ownership]);
/** Wrap any async action with pending-state tracking so the tree can show /** Wrap any async action with pending-state tracking so the tree can show
* a spinner over the corresponding button. Generic no domain knowledge. * a spinner over the corresponding button. Generic no domain knowledge.
* When `refreshAfterAction` is enabled, the spinner stays on until the * Always refetches all expanded parents after the action completes so the
* refreshed attributes have been written into state. */ * backend-authoritative values are rendered. */
const _runAction = useCallback( const _runAction = useCallback(
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => { async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
setPendingActions((prev) => { setPendingActions((prev) => {
@ -622,9 +646,7 @@ export function FormGeneratorTree<T = any>({
}); });
try { try {
await fn(); await fn();
if (refreshAfterAction || provider.refreshAttributes) { await _refreshVisibleAttributes();
await _refreshVisibleAttributes();
}
} finally { } finally {
setPendingActions((prev) => { setPendingActions((prev) => {
const next = new Map(prev); const next = new Map(prev);
@ -636,7 +658,7 @@ export function FormGeneratorTree<T = any>({
}); });
} }
}, },
[refreshAfterAction, _refreshVisibleAttributes], [_refreshVisibleAttributes],
); );
const _loadRoot = useCallback(async () => { const _loadRoot = useCallback(async () => {
@ -644,6 +666,7 @@ export function FormGeneratorTree<T = any>({
autoExpandedRef.current.clear(); autoExpandedRef.current.clear();
setExpandedIds(new Set()); setExpandedIds(new Set());
try { try {
setConfirmedEmptyFolderIds(new Set());
const rootNodes = await provider.loadChildren(null, ownership); const rootNodes = await provider.loadChildren(null, ownership);
setNodes(rootNodes); setNodes(rootNodes);
if (defaultCollapsed && rootNodes.length === 0) { if (defaultCollapsed && rootNodes.length === 0) {
@ -697,7 +720,10 @@ export function FormGeneratorTree<T = any>({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [nodes, provider, ownership, _mergeNodes]); }, [nodes, provider, ownership, _mergeNodes]);
const flatEntriesRaw = useMemo(() => _flatten(nodes, expandedIds), [nodes, expandedIds]); const flatEntriesRaw = useMemo(
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
[nodes, expandedIds, confirmedEmptyFolderIds],
);
const flatEntries = useMemo(() => { const flatEntries = useMemo(() => {
const term = filterText.trim().toLowerCase(); const term = filterText.trim().toLowerCase();
@ -730,19 +756,11 @@ export function FormGeneratorTree<T = any>({
const _handleToggleExpand = useCallback( const _handleToggleExpand = useCallback(
async (id: string) => { async (id: string) => {
const wasExpanded = expandedIds.has(id); const wasExpanded = expandedIds.has(id);
const node = nodes.find((n) => n.id === id);
if (wasExpanded) { if (wasExpanded) {
// Collapse: remove all descendants from nodes state and expandedIds. // Collapse: remove all descendants from nodes state and expandedIds.
const descendantIds = new Set<string>(); const descendantIds = new Set(_collectDescendantIds(id, nodes));
const _collectDescendants = (parentId: string) => {
for (const n of nodes) {
if (n.parentId === parentId && !descendantIds.has(n.id)) {
descendantIds.add(n.id);
_collectDescendants(n.id);
}
}
};
_collectDescendants(id);
setExpandedIds((prev) => { setExpandedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(id); next.delete(id);
@ -750,17 +768,30 @@ export function FormGeneratorTree<T = any>({
return next; return next;
}); });
setNodes((prev) => prev.filter((n) => !descendantIds.has(n.id))); setNodes((prev) => prev.filter((n) => !descendantIds.has(n.id)));
} else { return;
// Expand: load children from backend (always fresh). }
setExpandedIds((prev) => new Set([...prev, id]));
// Expand: load children from backend (fresh) and track empty folders so
// we can hide the chevron for confirmed-empty ones.
setExpandedIds((prev) => new Set([...prev, id]));
const childMap = _buildChildMap(nodes);
const existingChildren = childMap.get(id);
if (!existingChildren || existingChildren.length === 0) {
const childNodes = await provider.loadChildren(id, ownership); const childNodes = await provider.loadChildren(id, ownership);
if (childNodes.length > 0) { if (childNodes.length > 0) {
setNodes((prev) => _mergeNodes(prev, childNodes)); setNodes((prev) => _mergeNodes(prev, childNodes));
setConfirmedEmptyFolderIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
} else if (node?.type === 'folder') {
setConfirmedEmptyFolderIds((prev) => new Set(prev).add(id));
} }
setTimeout(() => {
_scrollExpandedNodeToCenter(id);
}, 50);
} }
setTimeout(() => {
_scrollExpandedNodeToCenter(id);
}, 50);
}, },
[nodes, expandedIds, provider, ownership, _mergeNodes], [nodes, expandedIds, provider, ownership, _mergeNodes],
); );
@ -881,6 +912,11 @@ export function FormGeneratorTree<T = any>({
// actually points at, so the new folder is visible. // actually points at, so the new folder is visible.
const visibleParent = newNode.parentId ?? null; const visibleParent = newNode.parentId ?? null;
if (visibleParent) { if (visibleParent) {
setConfirmedEmptyFolderIds((prev) => {
const next = new Set(prev);
next.delete(visibleParent);
return next;
});
setExpandedIds((prev) => new Set(prev).add(visibleParent)); setExpandedIds((prev) => new Set(prev).add(visibleParent));
} }
} catch { } catch {
@ -1097,6 +1133,7 @@ export function FormGeneratorTree<T = any>({
} }
case 'F2': { case 'F2': {
e.preventDefault(); e.preventDefault();
if (hideRowActionButtons) break;
const node = nodes.find((n) => n.id === focusedId); const node = nodes.find((n) => n.id === focusedId);
if (node && ownership === 'own' && provider.canRename?.(node)) { if (node && ownership === 'own' && provider.canRename?.(node)) {
_handleStartRename(focusedId); _handleStartRename(focusedId);
@ -1105,6 +1142,7 @@ export function FormGeneratorTree<T = any>({
} }
case 'Delete': { case 'Delete': {
e.preventDefault(); e.preventDefault();
if (hideRowActionButtons) break;
const node = nodes.find((n) => n.id === focusedId); const node = nodes.find((n) => n.id === focusedId);
if (node && ownership === 'own' && provider.canDelete?.(node)) { if (node && ownership === 'own' && provider.canDelete?.(node)) {
_handleDelete(focusedId); _handleDelete(focusedId);
@ -1124,6 +1162,7 @@ export function FormGeneratorTree<T = any>({
_handleToggleSelect, _handleToggleSelect,
_handleStartRename, _handleStartRename,
_handleDelete, _handleDelete,
hideRowActionButtons,
], ],
); );
@ -1139,6 +1178,8 @@ export function FormGeneratorTree<T = any>({
); );
}, [provider, ownership]); }, [provider, ownership]);
const dragDropEnabled = enableDragDrop ?? !hideRowActionButtons;
const _filteredIdsForAction = useCallback( const _filteredIdsForAction = useCallback(
(action: TreeBatchAction): string[] => { (action: TreeBatchAction): string[] => {
const ids = [...selectedIds]; const ids = [...selectedIds];
@ -1163,14 +1204,22 @@ export function FormGeneratorTree<T = any>({
const wrapperClasses = [ const wrapperClasses = [
styles.formGeneratorTree, styles.formGeneratorTree,
compact && styles.compactMode, compact && styles.compactMode,
embedMaxHeight != null && styles.embeddedPicker,
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
return ( return (
<div className={wrapperClasses}> <div
{title && ( className={wrapperClasses}
style={
embedMaxHeight != null
? { height: embedMaxHeight, maxHeight: embedMaxHeight, flexShrink: 0 }
: undefined
}
>
{title && !hideSectionHeader && (
<div <div
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`} className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined} onClick={collapsible ? () => setSectionCollapsed((v) => !v) : undefined}
@ -1236,7 +1285,7 @@ export function FormGeneratorTree<T = any>({
</div> </div>
)} )}
{selectable && selectedIds.size > 0 && batchActions.length > 0 && ( {selectable && selectedIds.size > 0 && batchActions.length > 0 && !hideRowActionButtons && (
<div className={styles.batchToolbar}> <div className={styles.batchToolbar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span> <span className={styles.batchCount}>{selectedIds.size} selected</span>
{batchActions.map((action: TreeBatchAction) => { {batchActions.map((action: TreeBatchAction) => {
@ -1318,6 +1367,8 @@ export function FormGeneratorTree<T = any>({
onDragOver={_handleDragOver} onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave} onDragLeave={_handleDragLeave}
onDrop={_handleDrop} onDrop={_handleDrop}
hideRowActionButtons={hideRowActionButtons}
dragDropEnabled={dragDropEnabled}
/> />
)) ))
)} )}

View file

@ -1106,19 +1106,15 @@ describe('FormGeneratorTree', () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// refreshAfterAction (backend-authoritative mode) // Always refetch after action (backend-authoritative, spec 2026-05-18)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('refreshAfterAction', () => { describe('refetch after action', () => {
it('refetches null + expanded parents after a flag toggle', async () => { it('refetches null + expanded parents after a flag toggle', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]); const provider = _createMockProvider([_ownFolder]);
render( render(
<FormGeneratorTree <FormGeneratorTree provider={provider} ownership="own" />,
provider={provider}
ownership="own"
refreshAfterAction
/>,
); );
await waitFor(() => { await waitFor(() => {
@ -1139,28 +1135,6 @@ describe('FormGeneratorTree', () => {
expect(newCalls.length).toBeGreaterThan(initialLoadCalls); expect(newCalls.length).toBeGreaterThan(initialLoadCalls);
expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true); expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true);
}); });
it('does NOT refetch when refreshAfterAction is false (default)', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const initialLoadCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
await waitFor(() => {
expect(provider.patchNeutralize).toHaveBeenCalled();
});
const newCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
expect(newCalls).toBe(initialLoadCalls);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -23,7 +23,9 @@ interface FileData {
sysCreatedBy?: string; sysCreatedBy?: string;
} }
function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode { function _mapFolderToNode(folder: FolderData, ownership: Ownership, allFolders: FolderData[], includeFilesInTree: boolean): TreeNode {
const hasSubfoldersInApiTree = allFolders.some((f) => (f.parentId ?? null) === folder.id);
const mayHaveLazyFileChildren = includeFilesInTree && !hasSubfoldersInApiTree;
return { return {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
@ -34,6 +36,8 @@ function _mapFolderToNode(folder: FolderData, ownership: Ownership): TreeNode {
neutralize: folder.neutralize, neutralize: folder.neutralize,
contextOrphan: folder.contextOrphan, contextOrphan: folder.contextOrphan,
icon: <FaFolder />, icon: <FaFolder />,
hasSubfoldersInApiTree,
mayHaveLazyFileChildren,
}; };
} }
@ -79,7 +83,8 @@ function _makeSyntheticRoot(ownership: Ownership): TreeNode {
}; };
} }
export function createFolderFileProvider(): TreeNodeProvider { export function createFolderFileProvider(options: { includeFiles?: boolean } = {}): TreeNodeProvider {
const includeFiles = options.includeFiles !== false;
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared'); const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
const typeMap = new Map<string, 'folder' | 'file'>(); const typeMap = new Map<string, 'folder' | 'file'>();
@ -150,41 +155,42 @@ export function createFolderFileProvider(): TreeNodeProvider {
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } }); const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
const allFolders: FolderData[] = foldersRes.data ?? []; const allFolders: FolderData[] = foldersRes.data ?? [];
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === apiParentId); const childFolders = allFolders.filter((f) => (f.parentId ?? null) === apiParentId);
const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership)); const folderNodes = childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles));
// Re-parent top-level folders onto the synthetic root.
if (apiParentId === null) { if (apiParentId === null) {
for (const n of folderNodes) n.parentId = synthRootId; for (const n of folderNodes) n.parentId = synthRootId;
} }
nodes.push(...folderNodes); nodes.push(...folderNodes);
try { if (includeFiles) {
const filters: Record<string, any> = {}; try {
if (apiParentId) { const filters: Record<string, any> = {};
filters.folderId = apiParentId; if (apiParentId) {
filters.folderId = apiParentId;
}
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', {
params: { pagination: paginationParam },
});
const data = filesRes.data;
let rawFiles: FileData[] = [];
if (data && typeof data === 'object' && 'items' in data) {
rawFiles = Array.isArray(data.items) ? data.items : [];
} else if (Array.isArray(data)) {
rawFiles = data;
}
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
if (apiParentId === null) {
for (const n of fileNodes) n.parentId = synthRootId;
}
nodes.push(...fileNodes);
} catch {
// file list may fail for shared trees; folders still render
} }
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', {
params: { pagination: paginationParam },
});
const data = filesRes.data;
let rawFiles: FileData[] = [];
if (data && typeof data === 'object' && 'items' in data) {
rawFiles = Array.isArray(data.items) ? data.items : [];
} else if (Array.isArray(data)) {
rawFiles = data;
}
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
if (apiParentId === null) {
for (const n of fileNodes) n.parentId = synthRootId;
}
nodes.push(...fileNodes);
} catch {
// file list may fail for shared trees; folders still render
} }
_trackTypes(nodes); _trackTypes(nodes);
@ -226,22 +232,9 @@ export function createFolderFileProvider(): TreeNodeProvider {
}, },
async createChild(parentId, name) { async createChild(parentId, name) {
// Creating a folder under "/" means a top-level folder; map back to null const res = await api.post('/api/files/folders', { name, parentId });
// for the API. The FE-only synth-root id never travels to the backend. const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
const apiParentId = parentId && parentId.startsWith('__filesRoot:') ? null : parentId; typeMap.set(node.id, 'folder');
const res = await api.post('/api/files/folders', { name, parentId: apiParentId });
const node = _mapFolderToNode(res.data, 'own');
// Bind the new folder visually to the parent the user actually clicked.
// - explicit synth-root parentId -> attach there ("/" + new top-level folder)
// - explicit parent (real folder) -> the API echoes the same parentId
// - parentId === null (no clicked parent, e.g. global "+" with no
// selection): default to the OWN tree's synth-root so the new folder
// shows up inside "/" instead of at the legacy top-level row.
if (parentId && parentId.startsWith('__filesRoot:')) {
node.parentId = parentId;
} else if (parentId === null) {
node.parentId = _SYNTH_ROOT_ID('own');
}
return node; return node;
}, },
@ -267,9 +260,8 @@ export function createFolderFileProvider(): TreeNodeProvider {
: targetParentId; : targetParentId;
await Promise.all( await Promise.all(
ids.map((id) => { ids.map((id) => {
if (id.startsWith('__filesRoot:')) return Promise.resolve();
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget }); if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: apiTarget });
return api.post(`/api/files/folders/${id}/move`, { targetParentId: apiTarget }); return api.post(`/api/files/folders/${id}/move`, { parentId: apiTarget });
}), }),
); );
}, },
@ -307,19 +299,6 @@ export function createFolderFileProvider(): TreeNodeProvider {
); );
}, },
async refreshAttributes(ids: string[]) {
const res = await api.post('/api/files/attributes', { ids });
const raw: Record<string, { neutralize?: boolean | 'mixed'; scope?: string | 'mixed' }> = res.data ?? {};
const result = new Map<string, { neutralize?: boolean | 'mixed'; scope?: ScopeValue | 'mixed' }>();
for (const [id, attrs] of Object.entries(raw)) {
result.set(id, {
neutralize: attrs.neutralize,
scope: attrs.scope as ScopeValue | 'mixed',
});
}
return result;
},
getBatchActions(): TreeBatchAction[] { getBatchActions(): TreeBatchAction[] {
return [ return [
{ {

View file

@ -47,6 +47,16 @@ export interface TreeNode<T = any> {
* pending spinner on click. Tree has no knowledge of action semantics. */ * pending spinner on click. Tree has no knowledge of action semantics. */
extraActions?: NodeAction[]; extraActions?: NodeAction[];
data?: T; data?: T;
/**
* From bulk `/folders/tree` response: another folder references this folder as parent.
* When false AND no lazy-file mode, omit expand affordance immediately.
*/
hasSubfoldersInApiTree?: boolean;
/**
* Folder tree mixes in files lazily (`includeFiles` in FolderFileProvider). When true but
* no subfolders in API snapshot, expand may still reveal files keep chevron until loaded.
*/
mayHaveLazyFileChildren?: boolean;
} }
export interface TreeBatchAction { export interface TreeBatchAction {
@ -78,16 +88,6 @@ export interface TreeNodeProvider<T = any> {
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>; patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
downloadNode?(node: TreeNode<T>): Promise<void>; downloadNode?(node: TreeNode<T>): Promise<void>;
getBatchActions?(): TreeBatchAction[]; getBatchActions?(): TreeBatchAction[];
/** After a toggle action, the tree collects all currently visible node IDs
* and calls this method. The provider asks the backend for the current
* attribute values (incl. mixed) of exactly those IDs. The tree then
* patches only the attribute fields on existing nodes no structural
* reload. If not implemented, the tree falls back to _refetchAllExpanded. */
refreshAttributes?(ids: string[]): Promise<Map<string, {
neutralize?: boolean | 'mixed';
scope?: ScopeValue | 'mixed';
ragIndexEnabled?: boolean | 'mixed';
}>>;
/** Called during drag-start to let the provider inject domain-specific MIME /** Called during drag-start to let the provider inject domain-specific MIME
* types into the DataTransfer (e.g. `application/datasource`). The generic * types into the DataTransfer (e.g. `application/datasource`). The generic
* tree always sets `application/tree-items` and `text/plain`; this hook * tree always sets `application/tree-items` and `text/plain`; this hook
@ -113,13 +113,16 @@ export interface FormGeneratorTreeProps<T = any> {
/** When false, hides checkboxes, multi-select keyboard bindings and the /** When false, hides checkboxes, multi-select keyboard bindings and the
* batch-action toolbar. Default true (backward compatible). */ * batch-action toolbar. Default true (backward compatible). */
selectable?: boolean; selectable?: boolean;
/** When true, after every flag-toggle / extra-action the tree refetches
* children for `null` and every currently expanded id, then atomically
* replaces the affected nodes. Optimistic local-state updates are skipped
* in this mode -- the backend is the single source of truth.
*
* Default `false` for backward-compat with FilesTab and other consumers
* that rely on the optimistic-update path. */
refreshAfterAction?: boolean;
className?: string; className?: string;
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
embedMaxHeight?: number;
/**
* Hides checkbox, size column, per-row emoji actions, and batch toolbar saves space in pickers.
* Drag-drop defaults off when hidden; pass `enableDragDrop` to keep moving folders inside the mini tree.
*/
hideRowActionButtons?: boolean;
/** When true, folders remain draggable despite `hideRowActionButtons`. */
enableDragDrop?: boolean;
/** Hides the titled section header (count, refresh, new folder) — for compact embedded pickers. */
hideSectionHeader?: boolean;
} }

View file

@ -0,0 +1,145 @@
/**
* Schedule planner layout + Felder; Akkordeon: UiComponents/AccordionList.
*/
.wrap {
max-width: 560px;
font-size: 14px;
color: var(--text-primary, #1a1a1a);
}
.intro {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary, #666);
}
.fields {
display: grid;
gap: 10px;
padding-top: 8px;
}
.field {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fieldStart {
align-items: flex-start;
}
.fieldLabel {
font-size: 13px;
color: var(--text-secondary, #666);
width: 120px;
flex-shrink: 0;
}
.fieldLabelTop {
padding-top: 4px;
}
.unit {
font-size: 13px;
color: var(--text-secondary, #666);
}
.timeRow {
display: flex;
align-items: center;
gap: 6px;
}
.timeSep {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary, #666);
}
.daysRow {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.dayBtn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.15));
background: var(--bg-secondary, rgba(0, 0, 0, 0.05));
color: var(--text-secondary, #555);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.dayBtn:hover {
border-color: var(--text-secondary, #888);
}
.dayBtnSel {
background: var(--text-primary, #1a1a1a);
color: var(--bg-primary, #fff);
border-color: var(--text-primary, #1a1a1a);
}
.numberInput {
width: 72px;
font-size: 14px;
padding: 6px 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
}
.select {
font-size: 14px;
padding: 6px 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
min-width: 4.5rem;
}
.cronInput {
font-family: ui-monospace, monospace;
font-size: 13px;
width: 100%;
max-width: 320px;
padding: 8px 10px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
}
.cronHint {
font-size: 11px;
color: var(--text-tertiary, #888);
margin-top: 8px;
line-height: 1.7;
max-width: 320px;
}
.warn {
font-size: 11px;
color: var(--warning-color, #b45309);
margin-top: 8px;
line-height: 1.5;
}
.disabled {
opacity: 0.55;
pointer-events: none;
}

View file

@ -0,0 +1,526 @@
/**
* Accordion schedule planner updates ScheduleSpec; parent derives cron via buildCronFromSpec.
*/
import React, { useCallback, useMemo, useState } from 'react';
import {
MINUTE_SELECT_OPTIONS,
WEEKDAYS_MO_SO,
type ScheduleSpec,
} from '../../utils/scheduleCron';
import { useLanguage } from '../../providers/language/LanguageContext';
import { AccordionList } from '../UiComponents';
import styles from './SchedulePlanner.module.css';
export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom';
const PLANNER_MODE_IDS: PlannerModeId[] = [
'minutes',
'hours',
'days',
'weeks',
'months',
'custom',
];
function plannerModeFromSpec(spec: ScheduleSpec): PlannerModeId {
const m = spec.mode;
if (m === 'interval') {
if (spec.intervalUnit === 'hours') return 'hours';
if (spec.intervalUnit === 'days') return 'days';
if (spec.intervalUnit === 'minutes') return 'minutes';
return 'custom';
}
if (m === 'minutes') return 'minutes';
if (m === 'hours') return 'hours';
if (m === 'days' || m === 'daily') return 'days';
if (m === 'weeks' || m === 'weekly' || m === 'weekdays') return 'weeks';
if (m === 'months' || (m === 'calendar' && spec.calendarPeriod === 'monthly')) return 'months';
return 'custom';
}
function withPlannerMode(spec: ScheduleSpec, id: PlannerModeId): ScheduleSpec {
const base = { ...spec };
switch (id) {
case 'minutes':
return {
...base,
mode: 'minutes',
intervalValue: Math.max(1, base.intervalValue || 15),
};
case 'hours':
return {
...base,
mode: 'hours',
intervalValue: Math.max(1, base.intervalValue || 1),
};
case 'days':
return {
...base,
mode: 'days',
intervalValue: Math.max(1, base.intervalValue || 1),
};
case 'weeks':
return {
...base,
mode: 'weeks',
weeksInterval: Math.max(1, base.weeksInterval || 1),
weekdays:
base.weekdays.length > 0 ? [...base.weekdays] : [1, 2, 3, 4, 5],
};
case 'months':
return {
...base,
mode: 'months',
intervalValue: Math.max(1, base.intervalValue || 1),
monthDay: Math.min(28, Math.max(1, base.monthDay || 1)),
};
case 'custom':
return {
...base,
mode: 'custom',
customCron: (base.customCron || '0 9 * * *').trim(),
};
default:
return base;
}
}
export interface SchedulePlannerProps {
value: ScheduleSpec;
onChange: (next: ScheduleSpec) => void;
className?: string;
disabled?: boolean;
}
export const SchedulePlanner: React.FC<SchedulePlannerProps> = ({
value,
onChange,
className = '',
disabled = false,
}) => {
const { t } = useLanguage();
const activeMode = useMemo(() => plannerModeFromSpec(value), [value]);
const [openId, setOpenId] = useState<PlannerModeId | null>(null);
const modeMeta = useMemo(
() =>
({
minutes: { label: t('Alle X Minuten') },
hours: { label: t('Alle X Stunden') },
days: { label: t('Täglich / alle X Tage') },
weeks: { label: t('Wöchentlich') },
months: { label: t('Monatlich') },
custom: { label: t('Custom (Cron)') },
}) as const,
[t]
);
const push = useCallback(
(next: ScheduleSpec) => {
if (!disabled) onChange(next);
},
[disabled, onChange]
);
const handleOpenChange = (next: PlannerModeId | null) => {
setOpenId(next);
if (next != null) {
push(withPlannerMode(value, next));
}
};
const hourOptions = useMemo(
() => Array.from({ length: 24 }, (_, i) => i),
[]
);
const renderBody = (id: PlannerModeId) => {
const v = plannerModeFromSpec(value) === id ? value : withPlannerMode(value, id);
switch (id) {
case 'minutes':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={59}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'minutes',
intervalValue: Math.min(59, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Minuten')}</span>
</div>
</div>
);
case 'hours':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={23}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'hours',
intervalValue: Math.min(23, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Stunden')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Bei Minute')}</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'hours',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
);
case 'days':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={31}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'days',
intervalValue: Math.min(31, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Tage')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'days',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'days',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
<p className={styles.cronHint}>
{t(
'Hinweis: „Alle N Tage“ entspricht in Cron einem Schritt im Tag-des-Monats-Feld, nicht zwingend jedem Kalendertag.'
)}
</p>
</div>
);
case 'weeks':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={52}
className={styles.numberInput}
value={v.weeksInterval ?? 1}
onChange={(e) =>
push({
...v,
mode: 'weeks',
weeksInterval: Math.min(52, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Wochen')}</span>
</div>
{(v.weeksInterval ?? 1) > 1 && (
<p className={styles.warn}>
{t(
'Mehr als jede Woche: der erzeugte Cron entspricht vorläufig wöchentlich (ein Wochen-Intervall > 1 ist im Standard-Cron nicht exakt abbildbar).'
)}
</p>
)}
<div className={`${styles.field} ${styles.fieldStart}`}>
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
{t('Wochentage')}
</span>
<div className={styles.daysRow}>
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
<button
key={cronDow}
type="button"
data-schedule-day=""
className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`}
onClick={() => {
const next = { ...v, mode: 'weeks' as const };
const set = new Set(next.weekdays);
if (set.has(cronDow)) set.delete(cronDow);
else set.add(cronDow);
let wd = [...set];
if (wd.length === 0) wd = [cronDow];
wd.sort((a, b) => {
const o = (x: number) => (x === 0 ? 7 : x);
return o(a) - o(b);
});
push({ ...next, weekdays: wd });
}}
>
{cronDow === 1
? t('Mo')
: cronDow === 2
? t('Di')
: cronDow === 3
? t('Mi')
: cronDow === 4
? t('Do')
: cronDow === 5
? t('Fr')
: cronDow === 6
? t('Sa')
: t('So')}
</button>
))}
</div>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'weeks',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'weeks',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
</div>
);
case 'months':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={12}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'months',
intervalValue: Math.min(12, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Monate')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Tag des Monats')}</span>
<input
type="number"
min={1}
max={28}
className={styles.numberInput}
value={Math.min(28, v.monthDay)}
onChange={(e) =>
push({
...v,
mode: 'months',
monthDay: Math.min(28, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'months',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'months',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
</div>
);
case 'custom':
return (
<div className={styles.fields}>
<div className={`${styles.field} ${styles.fieldStart}`}>
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
{t('Ausdruck')}
</span>
<div>
<input
type="text"
className={styles.cronInput}
value={v.customCron}
onChange={(e) =>
push({
...v,
mode: 'custom',
customCron: e.target.value,
})
}
spellCheck={false}
/>
<div className={styles.cronHint}>
{t('Cron')}: {t('Minute')} · {t('Stunde')} · {t('Tag')} · {t('Monat')} ·{' '}
{t('Wochentag')}
{' · '}[{t('Sekunde')} {t('optional')}]
<br />
{t('z.B.')}{' '}
<code>0 9 * * 3,5</code>
</div>
</div>
</div>
</div>
);
default:
return null;
}
};
return (
<div className={`${styles.wrap} ${className} ${disabled ? styles.disabled : ''}`}>
<p className={styles.intro}>
{t(
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
)}
</p>
<AccordionList<PlannerModeId>
items={PLANNER_MODE_IDS.map((mid) => ({
id: mid,
title: modeMeta[mid].label,
children: renderBody(mid),
}))}
showSelectionIndicator
selectedId={activeMode}
openId={openId}
onOpenChange={handleOpenChange}
disabled={disabled}
/>
</div>
);
};

View file

@ -0,0 +1 @@
export { SchedulePlanner, type SchedulePlannerProps, type PlannerModeId } from './SchedulePlanner';

View file

@ -0,0 +1,114 @@
/** Single-expand accordion — grid 0fr/1fr height animation. */
.list {
margin: 0;
padding: 0;
}
.item {
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
border-radius: var(--radius-md, 8px);
margin-bottom: 6px;
overflow: hidden;
background: var(--bg-primary, #fff);
}
.itemSelected {
border-color: var(--border-color, rgba(0, 0, 0, 0.14));
}
.header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
margin: 0;
border: none;
cursor: pointer;
user-select: none;
text-align: left;
background: transparent;
color: inherit;
font: inherit;
outline: none;
appearance: none;
}
.dotRail {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
flex-shrink: 0;
}
.selectionDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: transparent;
box-shadow: inset 0 0 0 1px var(--border-color, rgba(0, 0, 0, 0.22));
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.selectionDotOn {
background: var(--text-primary, #1a1a1a);
box-shadow: none;
}
.title {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
}
.chevron {
flex-shrink: 0;
margin-left: auto;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--text-tertiary, #888);
transition: transform 0.2s ease;
transform-origin: 50% 40%;
}
.itemExpanded .chevron {
transform: rotate(180deg);
}
.bodyCollapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.36s cubic-bezier(0.33, 1, 0.68, 1);
pointer-events: none;
}
.bodyCollapseOpen {
grid-template-rows: 1fr;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.bodyCollapse {
transition: none;
}
}
.bodyInner {
min-height: 0;
overflow: hidden;
}
.bodyPad {
padding: 0 12px 12px;
}
.nonInteractive {
pointer-events: none;
}

View file

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import styles from './AccordionList.module.css';
export interface AccordionListItem<T extends string = string> {
id: T;
title: React.ReactNode;
/** Always mounted while the list is mounted — enables smooth open/close. */
children: React.ReactNode;
}
export interface AccordionListProps<T extends string = string> {
items: AccordionListItem<T>[];
/** Optional left rail: dot marks the “selected” row (e.g. persisted value). */
showSelectionIndicator?: boolean;
selectedId?: T | null;
/** Controlled: currently open panel id, or `null` if all closed. */
openId?: T | null;
/** Uncontrolled initial open panel. Ignored when `openId` is passed. */
defaultOpenId?: T | null;
onOpenChange?: (openId: T | null) => void;
className?: string;
disabled?: boolean;
}
/**
* Single-expand accordion: one section open at a time; grid-row animation on real content height.
*/
export function AccordionList<T extends string = string>({
items,
showSelectionIndicator = false,
selectedId = null,
openId: openIdProp,
defaultOpenId = null,
onOpenChange,
className = '',
disabled = false,
}: AccordionListProps<T>): React.ReactElement {
const isControlled = openIdProp !== undefined;
const [openIdInternal, setOpenIdInternal] = useState<T | null>(defaultOpenId ?? null);
const openId = isControlled ? (openIdProp as T | null) : openIdInternal;
const setOpen = (next: T | null) => {
if (!isControlled) setOpenIdInternal(next);
onOpenChange?.(next);
};
const onToggle = (id: T) => {
if (disabled) return;
const next = openId === id ? null : id;
setOpen(next);
};
return (
<div
role="list"
className={`${styles.list} ${className} ${disabled ? styles.nonInteractive : ''}`}
>
{items.map((item) => {
const expanded = openId === item.id;
const isSelected = selectedId != null && selectedId === item.id;
return (
<div
key={item.id}
role="listitem"
className={`${styles.item} ${expanded ? styles.itemExpanded : ''} ${isSelected ? styles.itemSelected : ''}`}
>
<button
type="button"
className={styles.header}
aria-expanded={expanded}
disabled={disabled}
data-accordion-header=""
onClick={() => onToggle(item.id)}
>
{showSelectionIndicator ? (
<span className={styles.dotRail} aria-hidden>
<span
className={`${styles.selectionDot} ${isSelected ? styles.selectionDotOn : ''}`}
/>
</span>
) : null}
<span className={styles.title}>{item.title}</span>
<span className={styles.chevron} aria-hidden />
</button>
<div
className={`${styles.bodyCollapse} ${expanded ? styles.bodyCollapseOpen : ''}`}
aria-hidden={!expanded}
>
<div className={styles.bodyInner}>
<div className={styles.bodyPad}>{item.children}</div>
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,2 @@
export { AccordionList } from './AccordionList';
export type { AccordionListProps, AccordionListItem } from './AccordionList';

View file

@ -1,7 +1,12 @@
import React from 'react'; import React from 'react';
import { ButtonWithIconProps } from './ButtonTypes'; import { ButtonWithIconProps } from './ButtonTypes';
interface ButtonProps extends ButtonWithIconProps { type ButtonDomAccessibilityProps = Pick<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'title' | 'aria-label' | 'aria-busy' | 'aria-disabled' | 'aria-expanded' | 'aria-haspopup'
>;
interface ButtonProps extends ButtonWithIconProps, ButtonDomAccessibilityProps {
as?: 'button' | 'a'; as?: 'button' | 'a';
href?: string; href?: string;
} }

View file

@ -19,6 +19,7 @@ export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
export * from './AutoScroll'; export * from './AutoScroll';
export * from './Tabs'; export * from './Tabs';
export type { TabsProps, Tab } from './Tabs'; export type { TabsProps, Tab } from './Tabs';
export * from './AccordionList';
export * from './Toast'; export * from './Toast';
export * from './VoiceLanguageSelect'; export * from './VoiceLanguageSelect';
export * from './Modal'; export * from './Modal';

View file

@ -6,7 +6,7 @@
* *
* 1. Connection knowledgeIngestionEnabled master switch + mail/clickup prefs * 1. Connection knowledgeIngestionEnabled master switch + mail/clickup prefs
* 2. DataSource RAG-Limits maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants) * 2. DataSource RAG-Limits maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants)
* 3. Cost estimate indicative, non-binding USD figure * 3. Cost estimate indicative, non-binding CHF figure
* *
* Why a single modal: * Why a single modal:
* - The architectural rule is "no icon inflation in the UDB". One opens * - The architectural rule is "no icon inflation in the UDB". One opens
@ -302,7 +302,7 @@ export const DataSourceSettingsModal: React.FC<Props> = ({
}}> }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span> <span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span>
<span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedUsd.toFixed(4)} USD</span> <span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedChf.toFixed(4)} CHF</span>
</div> </div>
<div style={{ fontSize: 11, color: '#777', marginTop: 4 }}> <div style={{ fontSize: 11, color: '#777', marginTop: 4 }}>
~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes} ~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes}

View file

@ -201,7 +201,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
title={t('Eigene')} title={t('Eigene')}
compact={true} compact={true}
showFilter={true} showFilter={true}
refreshAfterAction
onNodeClick={_handleNodeClickWithImport} onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat} onSendToChat={_handleSendToChat}
/> />
@ -213,7 +212,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
compact={true} compact={true}
collapsible={true} collapsible={true}
defaultCollapsed={true} defaultCollapsed={true}
refreshAfterAction
emptyMessage={t('Keine geteilten Dateien')} emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport} onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat} onSendToChat={_handleSendToChat}

View file

@ -4,12 +4,12 @@
* SourcesTab UDB tab for personal connections + mandate data. * SourcesTab UDB tab for personal connections + mandate data.
* *
* Architecture: * Architecture:
* - Backend is the single source of truth (`POST /api/workspace/{instanceId}/tree/children`). * - Backend is the single source of truth (`POST /api/udb/tree/children`).
* - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider. * - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider.
* - Inheritance, mixed-state aggregation and cascade-NULL on patch are * - Inheritance, mixed-state aggregation and cascade-NULL on patch are
* ALL handled by the backend; the frontend never recomputes effective values. * ALL handled by the backend; the frontend never recomputes effective values.
* - Every flag toggle goes through `refreshAfterAction`: PATCH -> refetch all * - Every flag toggle: PATCH -> refetch all expanded parents via
* expanded parents -> atomic state replace. No optimistic updates. * loadChildren -> atomic state replace. No optimistic updates.
*/ */
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
@ -67,7 +67,6 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context }) => {
compact compact
selectable={false} selectable={false}
allowCreateFolder={false} allowCreateFolder={false}
refreshAfterAction
emptyMessage={t('Keine Datenquellen.')} emptyMessage={t('Keine Datenquellen.')}
/> />
</div> </div>

View file

@ -3,19 +3,28 @@
/** /**
* UdbSourcesProvider TreeNodeProvider for the UDB Sources tab. * UdbSourcesProvider TreeNodeProvider for the UDB Sources tab.
* *
* Single responsibility: translate the backend tree contract * Single responsibility: translate the generic UDB backend tree contract
* (POST /api/workspace/{instanceId}/tree/children nodesByParent map) into * (POST /api/udb/tree/children -> nodesByParent map) into the generic
* the generic TreeNode shape that FormGeneratorTree consumes, and forward * TreeNode shape that FormGeneratorTree consumes, and forward flag
* flag PATCHes to the existing /api/datasources/{id}/{flag} endpoints. * mutations to the single generic UDB endpoint
* (POST /api/udb/node/{key}/flag/{flag}).
* *
* No effective-value computation, no inheritance logic, no mixed-state math: * No effective-value computation, no inheritance logic, no mixed-state math:
* the backend is the single source of truth. The provider only: * the backend is the single source of truth. The provider only:
* 1. caches the most recently loaded backend node payload per id, so PATCHes * 1. caches the most recently loaded backend node payload per id so the
* can resolve the implicit DataSource record (creating it lazily when the * drag/settings handlers have direct access to coordinates,
* backend reports `canBeAdded=true`),
* 2. emits stable display ordering via `displayOrder`, * 2. emits stable display ordering via `displayOrder`,
* 3. hides flag affordances on synthetic container nodes (synthRoot, * 3. hides flag affordances on synthetic container nodes (synthRoot,
* mandateGroup) by leaving the corresponding TreeNode field undefined. * mandateGroup) by leaving the corresponding TreeNode field undefined.
*
* The UDB API endpoints are intentionally feature-agnostic; they do not
* carry an `instanceId` in the path. The legacy `instanceId` argument is
* still accepted by `createUdbSourcesProvider` because:
* - the drag payloads for "create personal DataSource" / "create FDS"
* hit feature-instance-scoped helper endpoints under /api/workspace/
* to keep ingestion/RAG bound to the caller's workspace,
* - the rootKey is namespaced per instance so two UDBs on the same
* screen never collide.
*/ */
import React from 'react'; import React from 'react';
@ -137,6 +146,11 @@ function _isSyntheticContainer(kind: UdbBackendKind): boolean {
return kind === 'synthRoot' || kind === 'mandateGroup'; return kind === 'synthRoot' || kind === 'mandateGroup';
} }
/** FDS-family kinds (no `scope` attribute; visibility is feature RBAC). */
function _isFdsKind(kind: UdbBackendKind): boolean {
return kind === 'featureNode' || kind === 'fdsTable' || kind === 'fdsRecord' || kind === 'fdsField';
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mapping: backend payload -> generic TreeNode // Mapping: backend payload -> generic TreeNode
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -165,7 +179,14 @@ function _mapBackendNode(
// Fields expose ONLY neutralize (mapped to parent table's // Fields expose ONLY neutralize (mapped to parent table's
// neutralizeFields list). Scope and RAG are not field-level concepts. // neutralizeFields list). Scope and RAG are not field-level concepts.
node.neutralize = n.effectiveNeutralize; node.neutralize = n.effectiveNeutralize;
} else if (_isFdsKind(n.kind)) {
// FDS records have neutralize + ragIndexEnabled, but no scope.
node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) {
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
}
} else { } else {
// DataSource family carries the full three-flag set.
node.scope = n.effectiveScope as ScopeValue | 'mixed'; node.scope = n.effectiveScope as ScopeValue | 'mixed';
node.neutralize = n.effectiveNeutralize; node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) { if (n.supportsRag) {
@ -201,11 +222,12 @@ export function createUdbSourcesProvider(
instanceId: string, instanceId: string,
onOpenSettings: (dataSourceId: string, label: string) => void, onOpenSettings: (dataSourceId: string, label: string) => void,
): UdbSourcesProviderHandle { ): UdbSourcesProviderHandle {
// Per-id cache of the most recent backend payload. Updated by every // Per-id cache of the most recent backend payload. Used by the
// `loadChildren` call. Read by patch/ensureRecord paths. // settings/drag handlers (NOT by the generic flag PATCH path; that
// identifies the node purely by its tree key).
const nodeCache = new Map<string, UdbBackendNode>(); const nodeCache = new Map<string, UdbBackendNode>();
async function _ensureRecord(node: UdbBackendNode): Promise<string | null> { async function _ensureRecordForSettings(node: UdbBackendNode): Promise<string | null> {
if (node.dataSourceId) return node.dataSourceId; if (node.dataSourceId) return node.dataSourceId;
try { try {
if (node.kind === 'connection' || node.kind === 'service' if (node.kind === 'connection' || node.kind === 'service'
@ -244,13 +266,13 @@ export function createUdbSourcesProvider(
return newId; return newId;
} }
} catch (err) { } catch (err) {
console.error('[UdbSourcesProvider] ensureRecord failed', err); console.error('[UdbSourcesProvider] ensureRecordForSettings failed', err);
} }
return null; return null;
} }
async function _onSettingsClick(node: UdbBackendNode): Promise<void> { async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
const dsId = await _ensureRecord(node); const dsId = await _ensureRecordForSettings(node);
if (!dsId) { if (!dsId) {
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key); console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
return; return;
@ -258,79 +280,19 @@ export function createUdbSourcesProvider(
onOpenSettings(dsId, node.label); onOpenSettings(dsId, node.label);
} }
/** fdsField-specific neutralize: ensure the parent fdsTable record exists, /** Forward a flag mutation to the generic UDB endpoint. The backend
* read its current `neutralizeFields` list, add or remove the field, * resolves the node from `nodeKey`, runs the polymorphic `canEdit`
* PATCH the new list back. Backend treats the FDS-record as the single * permission check, and applies the cascade-reset. */
* source of truth for per-field neutralization. */
async function _patchFieldNeutralize(fieldNodeId: string, neutralize: boolean): Promise<void> {
const fieldNode = nodeCache.get(fieldNodeId);
if (!fieldNode || fieldNode.kind !== 'fdsField') {
console.warn('[UdbSourcesProvider] field-neutralize target missing', fieldNodeId);
return;
}
const fieldName = fieldNode.fieldName;
const featureInstanceId = fieldNode.featureInstanceId;
const tableName = fieldNode.tableName;
if (!fieldName || !featureInstanceId || !tableName) {
console.warn('[UdbSourcesProvider] field-neutralize missing context', fieldNode);
return;
}
// Resolve the parent fdsTable record. Use the node's dataSourceId if
// already known (synthesized by the backend); otherwise create the
// record via _ensureRecord on a synthetic table-shaped node.
let dsId = fieldNode.dataSourceId;
if (!dsId) {
const tableNode: UdbBackendNode = {
...fieldNode,
kind: 'fdsTable',
key: `fdstbl|${featureInstanceId}|${tableName}`,
};
dsId = await _ensureRecord(tableNode);
}
if (!dsId) return;
// The parent fdsTable node carries `neutralizeFields` in its payload;
// pull it from the cache. Falls back to the field's effective state if
// the parent isn't cached for some reason.
const tableKey = `fdstbl|${featureInstanceId}|${tableName}`;
const tableNode = nodeCache.get(tableKey);
const currentList: string[] =
tableNode && Array.isArray(tableNode.neutralizeFields)
? [...tableNode.neutralizeFields]
: [];
const set = new Set(currentList);
if (neutralize) set.add(fieldName);
else set.delete(fieldName);
const newList = Array.from(set);
try {
await api.patch(`/api/datasources/${dsId}/neutralize-fields`, { neutralizeFields: newList });
// Keep the cache in sync so subsequent toggles in the same session
// start from the right baseline.
if (tableNode) {
nodeCache.set(tableKey, { ...tableNode, neutralizeFields: newList });
}
} catch (err) {
console.error('[UdbSourcesProvider] patch neutralize-fields failed', { fieldNodeId, err });
throw err;
}
}
async function _patchFlag( async function _patchFlag(
ids: string[], ids: string[],
flag: 'scope' | 'neutralize' | 'rag-index', flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
body: Record<string, unknown>, value: unknown,
): Promise<void> { ): Promise<void> {
for (const id of ids) { for (const nodeKey of ids) {
const cached = nodeCache.get(id);
if (!cached) {
console.warn('[UdbSourcesProvider] patch target not in cache', id);
continue;
}
const dsId = await _ensureRecord(cached);
if (!dsId) continue;
try { try {
await api.patch(`/api/datasources/${dsId}/${flag}`, body); await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/${flag}`, { value });
} catch (err) { } catch (err) {
console.error('[UdbSourcesProvider] patch failed', { id, flag, err }); console.error('[UdbSourcesProvider] patch failed', { nodeKey, flag, err });
throw err; throw err;
} }
} }
@ -340,7 +302,7 @@ export function createUdbSourcesProvider(
rootKey: `udb-sources-${instanceId}`, rootKey: `udb-sources-${instanceId}`,
async loadChildren(parentId, _ownership) { async loadChildren(parentId, _ownership) {
const res = await api.post(`/api/workspace/${instanceId}/tree/children`, { const res = await api.post(`/api/udb/tree/children`, {
parents: [parentId], parents: [parentId],
}); });
const nodesByParent = res.data?.nodesByParent || {}; const nodesByParent = res.data?.nodesByParent || {};
@ -352,8 +314,8 @@ export function createUdbSourcesProvider(
canPatchScope(node) { canPatchScope(node) {
const data = node.data; const data = node.data;
// Field-level scope makes no sense; it's inherited from the parent table. // Scope only exists on DataSource family; FDS / synthetic containers / fields hide it.
return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField'; return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind);
}, },
canPatchNeutralize(node) { canPatchNeutralize(node) {
@ -363,7 +325,7 @@ export function createUdbSourcesProvider(
canPatchRagIndex(node) { canPatchRagIndex(node) {
const data = node.data; const data = node.data;
// RAG is not a field-level concept either; only the table-record carries it. // RAG exists at the data-source level (DS root / FDS table+rows), never on fields.
return !!data && data.supportsRag === true && data.kind !== 'fdsField'; return !!data && data.supportsRag === true && data.kind !== 'fdsField';
}, },
@ -371,26 +333,15 @@ export function createUdbSourcesProvider(
// Backend cascades NULL on descendants automatically based on the // Backend cascades NULL on descendants automatically based on the
// existence of explicit child records; the cascadeChildren flag is the // existence of explicit child records; the cascadeChildren flag is the
// FilesTab convention and is irrelevant here. // FilesTab convention and is irrelevant here.
await _patchFlag(ids, 'scope', { scope }); await _patchFlag(ids, 'scope', scope);
}, },
async patchNeutralize(ids, neutralize) { async patchNeutralize(ids, neutralize) {
// fdsField nodes don't have their own DB record — they are addressed await _patchFlag(ids, 'neutralize', neutralize);
// via the parent fdsTable's `neutralizeFields` array. Split the batch
// accordingly and dispatch each kind to the right endpoint.
const fieldIds: string[] = [];
const otherIds: string[] = [];
for (const id of ids) {
const cached = nodeCache.get(id);
if (cached?.kind === 'fdsField') fieldIds.push(id);
else otherIds.push(id);
}
if (otherIds.length > 0) await _patchFlag(otherIds, 'neutralize', { neutralize });
for (const fieldId of fieldIds) await _patchFieldNeutralize(fieldId, neutralize);
}, },
async patchRagIndex(ids, ragIndexEnabled) { async patchRagIndex(ids, ragIndexEnabled) {
await _patchFlag(ids, 'rag-index', { ragIndexEnabled }); await _patchFlag(ids, 'ragIndexEnabled', ragIndexEnabled);
}, },
customizeDragData(node, dataTransfer) { customizeDragData(node, dataTransfer) {
@ -424,30 +375,6 @@ export function createUdbSourcesProvider(
} }
}, },
async refreshAttributes(ids: string[]) {
const res = await api.post(`/api/workspace/${instanceId}/tree/attributes`, {
keys: ids,
});
const raw: Record<string, {
effectiveNeutralize?: boolean | 'mixed';
effectiveScope?: string | 'mixed';
effectiveRagIndexEnabled?: boolean | 'mixed';
}> = res.data?.attributes ?? {};
const result = new Map<string, {
neutralize?: boolean | 'mixed';
scope?: ScopeValue | 'mixed';
ragIndexEnabled?: boolean | 'mixed';
}>();
for (const [key, attrs] of Object.entries(raw)) {
result.set(key, {
neutralize: attrs.effectiveNeutralize,
scope: attrs.effectiveScope as ScopeValue | 'mixed',
ragIndexEnabled: attrs.effectiveRagIndexEnabled,
});
}
return result;
},
_diagnosticGetCacheSize() { _diagnosticGetCacheSize() {
return nodeCache.size; return nodeCache.size;
}, },

View file

@ -60,6 +60,50 @@ function _makeSynthRootNode(): UdbBackendNode {
}; };
} }
function _makeFdsTableNode(): UdbBackendNode {
return {
key: 'fdstbl|fi1|Kontakte',
kind: 'fdsTable',
parentKey: 'feat|m1|trustee|fi1',
label: 'Kontakte',
icon: 'table',
hasChildren: true,
dataSourceId: 'fds-1',
modelType: 'FeatureDataSource',
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: true,
canBeAdded: false,
featureInstanceId: 'fi1',
featureCode: 'trustee',
tableName: 'Kontakte',
objectKey: 'data.feature.trustee.Kontakte',
neutralizeFields: [],
};
}
function _makeFdsFieldNode(): UdbBackendNode {
return {
key: 'fdsfld|fi1|Kontakte|email',
kind: 'fdsField',
parentKey: 'fdstbl|fi1|Kontakte',
label: 'email',
icon: 'field',
hasChildren: false,
dataSourceId: 'fds-1',
modelType: 'FeatureDataSource',
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: false,
canBeAdded: false,
featureInstanceId: 'fi1',
tableName: 'Kontakte',
fieldName: 'email',
};
}
const _instanceId = 'inst-42'; const _instanceId = 'inst-42';
beforeEach(() => { beforeEach(() => {
@ -68,23 +112,23 @@ beforeEach(() => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// loadChildren // loadChildren -> POST /api/udb/tree/children (feature-agnostic)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('UdbSourcesProvider.loadChildren', () => { describe('UdbSourcesProvider.loadChildren', () => {
it('calls POST /api/workspace/{instanceId}/tree/children with parents=[parentId]', async () => { it('calls POST /api/udb/tree/children with parents=[parentId]', async () => {
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } }); apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren(null, 'own'); await provider.loadChildren(null, 'own');
expect(apiMock.post).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/workspace/${_instanceId}/tree/children`, `/api/udb/tree/children`,
{ parents: [null] }, { parents: [null] },
); );
}); });
it('maps backend nodes to TreeNode shape with flag-bearer fields', async () => { it('maps DS-family backend nodes to TreeNode shape with all three flags', async () => {
const conn = _makeBackendNode(); const conn = _makeBackendNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } }); apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
@ -94,103 +138,42 @@ describe('UdbSourcesProvider.loadChildren', () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
const tn = result[0]; const tn = result[0];
expect(tn.id).toBe('conn|c1'); expect(tn.id).toBe('conn|c1');
expect(tn.name).toBe('My Microsoft');
expect(tn.parentId).toBe('personalRoot');
expect(tn.ownership).toBe('own');
expect(tn.scope).toBe('personal'); expect(tn.scope).toBe('personal');
expect(tn.neutralize).toBe(false); expect(tn.neutralize).toBe(false);
expect(tn.ragIndexEnabled).toBe(false); expect(tn.ragIndexEnabled).toBe(false);
expect(tn.type).toBe('folder');
}); });
it('hides scope/neutralize/ragIndexEnabled on synthetic containers', async () => { it('FDS table maps to only 2 flags (no scope)', async () => {
const tbl = _makeFdsTableNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'feat|m1|trustee|fi1': [tbl] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [tn] = await provider.loadChildren('feat|m1|trustee|fi1', 'own');
expect(tn.neutralize).toBe(false);
expect(tn.ragIndexEnabled).toBe(false);
expect(tn.scope).toBeUndefined();
});
it('FDS field maps to only neutralize (no scope, no rag)', async () => {
const fld = _makeFdsFieldNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'fdstbl|fi1|Kontakte': [fld] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [tn] = await provider.loadChildren('fdstbl|fi1|Kontakte', 'own');
expect(tn.neutralize).toBe(false);
expect(tn.scope).toBeUndefined();
expect(tn.ragIndexEnabled).toBeUndefined();
});
it('hides all flags on synthetic containers', async () => {
const root = _makeSynthRootNode(); const root = _makeSynthRootNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } }); apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const result = await provider.loadChildren(null, 'own'); const [tn] = await provider.loadChildren(null, 'own');
expect(tn.scope).toBeUndefined();
expect(result).toHaveLength(1); expect(tn.neutralize).toBeUndefined();
expect(result[0].scope).toBeUndefined(); expect(tn.ragIndexEnabled).toBeUndefined();
expect(result[0].neutralize).toBeUndefined();
expect(result[0].ragIndexEnabled).toBeUndefined();
expect(result[0].displayOrder).toBe(0);
});
it('omits ragIndexEnabled when supportsRag is false', async () => {
const node = _makeBackendNode({
key: 'mgrp|m1',
kind: 'mandateGroup',
parentKey: null,
supportsRag: false,
});
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [node] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const result = await provider.loadChildren(null, 'own');
expect(result[0].ragIndexEnabled).toBeUndefined();
expect(result[0].scope).toBeUndefined();
expect(result[0].neutralize).toBeUndefined();
});
it('attaches the settings extraAction on every data-source-root, even without a record yet', async () => {
const onSettings = vi.fn();
const withId = _makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false });
const withoutId = _makeBackendNode({ key: 'conn|c2', dataSourceId: null });
apiMock.post.mockResolvedValue({
data: { nodesByParent: { personalRoot: [withId, withoutId] } },
});
const provider = createUdbSourcesProvider(_instanceId, onSettings);
const result = await provider.loadChildren('personalRoot', 'own');
expect(result[0].extraActions).toHaveLength(1);
expect(result[0].extraActions?.[0].key).toBe('settings');
await result[0].extraActions?.[0].onClick?.();
expect(onSettings).toHaveBeenCalledWith('ds-1', 'My Microsoft');
// The conn without a record still gets a settings button (always visible
// on data-source-roots). Click triggers an _ensureRecord POST first.
expect(result[1].extraActions).toHaveLength(1);
expect(result[1].extraActions?.[0].key).toBe('settings');
});
it('hides the settings extraAction on non-root nodes (folders, files, services, ...)', async () => {
const folder = _makeBackendNode({ kind: 'folder', dataSourceId: 'ds-9' });
apiMock.post.mockResolvedValue({
data: { nodesByParent: { 'conn|c1': [folder] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const result = await provider.loadChildren('conn|c1', 'own');
expect(result[0].extraActions).toBeUndefined();
});
it('forwards defaultExpanded from backend payload to the TreeNode', async () => {
const expanded = _makeBackendNode({
key: 'personalRoot',
kind: 'synthRoot',
defaultExpanded: true,
});
apiMock.post.mockResolvedValue({
data: { nodesByParent: { __root__: [expanded] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [node] = await provider.loadChildren(null, 'own');
expect(node.defaultExpanded).toBe(true);
});
it('populates the internal cache so subsequent patches can resolve nodes', async () => {
apiMock.post.mockResolvedValue({
data: { nodesByParent: { personalRoot: [_makeBackendNode()] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
expect(provider._diagnosticGetCacheSize()).toBe(0);
await provider.loadChildren('personalRoot', 'own');
expect(provider._diagnosticGetCacheSize()).toBe(1);
}); });
}); });
@ -199,7 +182,7 @@ describe('UdbSourcesProvider.loadChildren', () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('UdbSourcesProvider.canPatch*', () => { describe('UdbSourcesProvider.canPatch*', () => {
it('canPatchScope is false for synthetic containers', async () => { it('canPatch* all false for synthetic containers', async () => {
apiMock.post.mockResolvedValue({ apiMock.post.mockResolvedValue({
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } }, data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
}); });
@ -210,175 +193,84 @@ describe('UdbSourcesProvider.canPatch*', () => {
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false); expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
}); });
it('canPatchRagIndex requires supportsRag=true', async () => { it('canPatchScope is false for any FDS kind', async () => {
apiMock.post.mockResolvedValue({ apiMock.post.mockResolvedValue({
data: { data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } },
nodesByParent: {
personalRoot: [
_makeBackendNode({ key: 'a', supportsRag: true }),
_makeBackendNode({ key: 'b', supportsRag: false }),
],
},
},
}); });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [a, b] = await provider.loadChildren('personalRoot', 'own'); const [tbl] = await provider.loadChildren('feat|m1|trustee|fi1', 'own');
expect(provider.canPatchRagIndex?.(a)).toBe(true); expect(provider.canPatchScope?.(tbl)).toBe(false);
expect(provider.canPatchRagIndex?.(b)).toBe(false); expect(provider.canPatchNeutralize?.(tbl)).toBe(true);
expect(provider.canPatchRagIndex?.(tbl)).toBe(true);
});
it('canPatchRagIndex is false on fdsField (only neutralize at field level)', async () => {
apiMock.post.mockResolvedValue({
data: { nodesByParent: { 'fdstbl|fi1|Kontakte': [_makeFdsFieldNode()] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [fld] = await provider.loadChildren('fdstbl|fi1|Kontakte', 'own');
expect(provider.canPatchNeutralize?.(fld)).toBe(true);
expect(provider.canPatchRagIndex?.(fld)).toBe(false);
expect(provider.canPatchScope?.(fld)).toBe(false);
}); });
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// patch flow: ensureRecord + PATCH // patch flow -> POST /api/udb/node/{key}/flag/{flag}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('UdbSourcesProvider.patchScope', () => { describe('UdbSourcesProvider.patchScope', () => {
it('PATCHes existing dataSourceId without creating a new record', async () => { it('POSTs to /api/udb/node/{key}/flag/scope with the new value', async () => {
apiMock.post.mockResolvedValueOnce({ apiMock.post.mockResolvedValue({ data: {} });
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-existing', canBeAdded: false })],
},
},
});
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchScope?.(['conn|c1'], 'mandate', true); await provider.patchScope?.(['conn|c1'], 'mandate', true);
expect(apiMock.patch).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/datasources/ds-existing/scope`, `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/scope`,
{ scope: 'mandate' }, { value: 'mandate' },
); );
// Only one POST: the loadChildren call. No POST datasources.
expect(apiMock.post).toHaveBeenCalledTimes(1);
});
it('creates a DataSource record first when canBeAdded=true', async () => {
apiMock.post
.mockResolvedValueOnce({
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: null, canBeAdded: true })],
},
},
})
.mockResolvedValueOnce({ data: { id: 'ds-new' } });
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchScope?.(['conn|c1'], 'mandate', true);
expect(apiMock.post).toHaveBeenNthCalledWith(
2,
`/api/workspace/${_instanceId}/datasources`,
expect.objectContaining({
connectionId: 'c1',
sourceType: 'msft',
path: '/',
label: 'My Microsoft',
}),
);
expect(apiMock.patch).toHaveBeenCalledWith(
`/api/datasources/ds-new/scope`,
{ scope: 'mandate' },
);
});
it('skips silently when target node is not in cache', async () => {
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.patchScope?.(['unknown'], 'personal', false);
expect(apiMock.patch).not.toHaveBeenCalled();
}); });
}); });
describe('UdbSourcesProvider.patchNeutralize', () => { describe('UdbSourcesProvider.patchNeutralize', () => {
it('PATCHes /neutralize with the supplied boolean', async () => { it('POSTs to /api/udb/node/{key}/flag/neutralize', async () => {
apiMock.post.mockResolvedValueOnce({ apiMock.post.mockResolvedValue({ data: {} });
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
},
},
});
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchNeutralize?.(['conn|c1'], true); await provider.patchNeutralize?.(['conn|c1'], true);
expect(apiMock.patch).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/datasources/ds-1/neutralize`, `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/neutralize`,
{ neutralize: true }, { value: true },
);
});
it('uses the same generic endpoint for FDS field nodes (no kind-split)', async () => {
apiMock.post.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.patchNeutralize?.(['fdsfld|fi1|Kontakte|email'], true);
expect(apiMock.post).toHaveBeenCalledTimes(1);
expect(apiMock.post).toHaveBeenCalledWith(
`/api/udb/node/${encodeURIComponent('fdsfld|fi1|Kontakte|email')}/flag/neutralize`,
{ value: true },
); );
}); });
}); });
describe('UdbSourcesProvider.patchRagIndex', () => { describe('UdbSourcesProvider.patchRagIndex', () => {
it('PATCHes /rag-index with the supplied boolean (note dash in URL, camelCase in body)', async () => { it('POSTs to /api/udb/node/{key}/flag/ragIndexEnabled', async () => {
apiMock.post.mockResolvedValueOnce({ apiMock.post.mockResolvedValue({ data: {} });
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
},
},
});
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchRagIndex?.(['conn|c1'], true); await provider.patchRagIndex?.(['conn|c1'], true);
expect(apiMock.patch).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/datasources/ds-1/rag-index`, `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/ragIndexEnabled`,
{ ragIndexEnabled: true }, { value: true },
);
});
it('routes to feature-datasources when the cached node is a featureNode', async () => {
const featureNode: UdbBackendNode = {
key: 'feat|m1|trustee|inst-1',
kind: 'featureNode',
parentKey: 'mgrp|m1',
label: 'Trustee',
icon: 'mdi-database',
hasChildren: true,
dataSourceId: null,
modelType: null,
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: true,
canBeAdded: true,
featureInstanceId: 'inst-1',
featureCode: 'trustee',
mandateId: 'm1',
tableName: '*',
};
apiMock.post
.mockResolvedValueOnce({ data: { nodesByParent: { 'mgrp|m1': [featureNode] } } })
.mockResolvedValueOnce({ data: { id: 'fds-new' } });
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('mgrp|m1', 'own');
await provider.patchRagIndex?.([featureNode.key], true);
expect(apiMock.post).toHaveBeenNthCalledWith(
2,
`/api/workspace/${_instanceId}/feature-datasources`,
expect.objectContaining({
featureInstanceId: 'inst-1',
featureCode: 'trustee',
tableName: '*',
objectKey: 'data.feature.trustee.*',
}),
);
expect(apiMock.patch).toHaveBeenCalledWith(
`/api/datasources/fds-new/rag-index`,
{ ragIndexEnabled: true },
); );
}); });
}); });

View file

@ -0,0 +1,45 @@
import type { KeepAliveEntry } from '../types/keepAlive.types';
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
import { CommcoachSessionView } from '../pages/views/commcoach';
import { GraphicalEditorPage } from '../pages/views/graphicalEditor/GraphicalEditorPage';
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
{
id: 'workspace-dashboard',
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
scopeRegex: /\/mandates\/([^/]+)\/workspace\/([^/]+)/,
requireMandateForMount: false,
render: ({ instanceId, scopeKey }) => (
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
),
},
{
id: 'commcoach-session',
pathRegex: /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/,
scopeRegex: /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/,
shellOverflowHidden: false,
render: ({ scopeKey }) => <CommcoachSessionView key={scopeKey} />,
},
{
id: 'graphical-editor',
pathRegex: /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/,
scopeRegex: /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/,
render: ({ mandateId, instanceId, scopeKey }) => (
<GraphicalEditorPage
key={scopeKey}
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
),
},
{
id: 'admin-languages',
pathRegex: /\/admin\/languages(?:$|\/)/,
render: () => <AdminLanguagesPage />,
},
];
export function hideFeatureOutlet(pathname: string): boolean {
return KEEP_ALIVE_ROUTES.some((e) => e.pathRegex.test(pathname));
}

View file

@ -136,7 +136,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.chatworkflow': <FaPlay />, 'feature.chatworkflow': <FaPlay />,
'feature.graphicalEditor': <FaProjectDiagram />, 'feature.graphicalEditor': <FaProjectDiagram />,
'page.feature.graphicalEditor.editor': <FaProjectDiagram />, 'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />, 'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
'page.feature.chatbot.conversations': <FaComments />, 'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />, 'feature.chatbot': <FaComments />,

View file

@ -1,473 +0,0 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { allPageData, SidebarItem, SidebarSubmenuItemData } from './data';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText, GenericPageData } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions';
import { FaHome, FaHatWizard, FaBriefcase, FaBuilding, FaProjectDiagram } from 'react-icons/fa';
import { RiFolderSettingsFill } from 'react-icons/ri';
// Configuration for parent groups that don't have a page definition
// Maps parentPath (can be nested like "start.real-estate") to icon and default order
const parentGroupConfig: Record<string, {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
defaultOrder?: number;
}> = {
'start': {
icon: FaHome,
defaultOrder: 1
},
'workflows': {
icon: FaProjectDiagram,
defaultOrder: 2
},
'trustee': {
icon: FaBriefcase,
defaultOrder: 3
},
'basedata': {
icon: RiFolderSettingsFill,
defaultOrder: 4
},
'admin': {
icon: FaHatWizard,
defaultOrder: 5
},
'start.realestate': {
icon: FaBuilding,
defaultOrder: 2
}
};
interface SidebarContextType {
sidebarItems: SidebarItem[];
loading: boolean;
error: string | null;
refreshSidebar: () => Promise<void>;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export const useSidebar = () => {
const context = useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
};
interface SidebarProviderProps {
children: React.ReactNode;
}
export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) => {
const [sidebarItems, setSidebarItems] = useState<SidebarItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get translation function from language context
const { t } = useLanguage();
const { canView, preloadUiPermissions } = usePermissions();
// Helper type for navigation tree nodes
interface NavigationNode {
id: string;
pathSegment: string;
fullPath: string; // Full dot-notation path (e.g., "start.real-estate")
name: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
order: number;
page?: GenericPageData; // If this node represents an actual page
children: Map<string, NavigationNode>; // Keyed by path segment
pages: GenericPageData[]; // Direct child pages
}
// Helper function to resolve node name
const resolveNodeName = (pathSegment: string, _fullPath: string, page?: GenericPageData): string => {
if (page) {
return resolveLanguageText(page.name, t);
}
return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
};
// Helper function to resolve node icon
const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType<React.SVGProps<SVGSVGElement>> | undefined => {
if (page?.icon) {
return page.icon;
}
// Check parentGroupConfig for nested paths first (e.g., "start.real-estate")
if (parentGroupConfig[fullPath]?.icon) {
return parentGroupConfig[fullPath].icon;
}
// Check parentGroupConfig for top-level segments (e.g., "start")
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) {
return parentGroupConfig[pathSegment].icon;
}
return undefined;
};
// Helper function to resolve node order
const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => {
if (page?.order !== undefined) {
return page.order;
}
// Check parentGroupConfig for top-level segments
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) {
return parentGroupConfig[pathSegment].defaultOrder!;
}
// Use minimum order of child pages
if (childPages.length > 0) {
const childOrders = childPages.map(p => p.order ?? 0);
return Math.min(...childOrders);
}
return 0;
};
// Build navigation tree from page data
const buildNavigationTree = (): Map<string, NavigationNode> => {
const rootNodes = new Map<string, NavigationNode>();
// Process all pages with parent paths
const pagesWithParents = allPageData.filter(
page => page.parentPath && !page.hide && page.showInSidebar !== false
);
for (const page of pagesWithParents) {
if (!page.parentPath) continue;
// Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"])
const pathSegments = page.parentPath.split('.');
// Build path to root, creating nodes as needed
let currentMap = rootNodes;
let currentFullPath = '';
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i];
currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment;
// Get or create node for this segment
if (!currentMap.has(segment)) {
// Check if there's a page for this path segment
const segmentPage = allPageData.find(
p => p.path === currentFullPath && !p.hide
);
const node: NavigationNode = {
id: segmentPage?.id || currentFullPath,
pathSegment: segment,
fullPath: currentFullPath,
name: '', // Will be resolved later
icon: undefined, // Will be resolved later
order: 0, // Will be resolved later
page: segmentPage,
children: new Map(),
pages: []
};
currentMap.set(segment, node);
}
const node = currentMap.get(segment)!;
// If this is the last segment, add the page as a child page
if (i === pathSegments.length - 1) {
node.pages.push(page);
}
// Move to next level
currentMap = node.children;
}
}
// Resolve names, icons, and orders for all nodes
const resolveNode = (node: NavigationNode): void => {
// Resolve children first (bottom-up)
for (const childNode of node.children.values()) {
resolveNode(childNode);
}
// Resolve this node
node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page);
node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page);
// Collect all child pages (from direct pages and nested children)
const allChildPages = [...node.pages];
for (const childNode of node.children.values()) {
if (childNode.page) {
allChildPages.push(childNode.page);
}
allChildPages.push(...childNode.pages);
}
node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages);
};
// Resolve all root nodes
for (const node of rootNodes.values()) {
resolveNode(node);
}
return rootNodes;
};
// Convert navigation tree node to sidebar submenu item (recursive)
const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise<SidebarSubmenuItemData | null> => {
// Filter child pages by RBAC and privilegeChecker
const accessiblePages: GenericPageData[] = [];
for (const page of node.pages) {
try {
const hasRBACAccess = await canView('UI', page.path);
if (!hasRBACAccess) continue;
if (page.privilegeChecker) {
try {
const hasPrivilege = await page.privilegeChecker();
if (!hasPrivilege) continue;
} catch (error) {
console.error(`Error checking privilegeChecker for page ${page.path}:`, error);
continue;
}
}
accessiblePages.push(page);
} catch (error) {
console.error(`Error checking RBAC access for page ${page.path}:`, error);
}
}
// Process child nodes recursively (increment depth)
const accessibleChildren: SidebarSubmenuItemData[] = [];
for (const childNode of node.children.values()) {
const childItem = await nodeToSubmenuItem(childNode, depth + 1);
if (childItem) {
accessibleChildren.push(childItem);
}
}
// Combine pages and child nodes, assigning depth
const allChildren: SidebarSubmenuItemData[] = [
...accessiblePages.map(page => ({
id: page.id,
name: resolveLanguageText(page.name, t),
link: `/${page.path}`,
icon: page.icon,
depth: depth + 1 // Child pages are one level deeper
})),
...accessibleChildren
];
// If no accessible children, don't create this node
if (allChildren.length === 0) {
return null;
}
// If this node has a page itself, it shouldn't be a navigation node
// But according to requirements: if it has subpages, it is NOT a page itself
// So we create a navigation node without a link
return {
id: node.id,
name: node.name,
link: undefined, // Navigation node - not a clickable page
icon: node.icon,
submenu: allChildren.length > 0 ? allChildren : undefined,
depth: depth // Current depth level
};
};
// Convert navigation tree to sidebar items
const treeToSidebarItems = async (tree: Map<string, NavigationNode>): Promise<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Process each root node (depth 0 for top-level items)
for (const node of tree.values()) {
const submenuItem = await nodeToSubmenuItem(node, 0);
if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) {
items.push({
id: node.id,
name: node.name,
link: undefined, // Navigation node - not a clickable page
icon: node.icon,
moduleEnabled: true,
order: node.order,
submenu: submenuItem.submenu,
depth: 0 // Top-level items have depth 0
});
}
}
return items;
};
// Get sidebar items from page data
const getSidebarItems = async (): Promise<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Build navigation tree
const navigationTree = buildNavigationTree();
// Convert tree to sidebar items
const treeItems = await treeToSidebarItems(navigationTree);
items.push(...treeItems);
// Get main pages (no parent path)
const mainPages = allPageData
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0));
// Process each main page
for (const pageData of mainPages) {
// Check RBAC permissions
try {
const hasRBACAccess = await canView('UI', pageData.path);
if (!hasRBACAccess) {
continue;
}
// Check client-side privilegeChecker if provided
if (pageData.privilegeChecker) {
try {
const hasPrivilege = await pageData.privilegeChecker();
if (!hasPrivilege) {
continue;
}
} catch (error) {
console.error(`Error checking privilegeChecker for ${pageData.path}:`, error);
continue;
}
}
} catch (error) {
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
continue;
}
// Check if this page has subpages (legacy support)
if (pageData.hasSubpages) {
// Find all subpages for this parent
const allSubpages = allPageData.filter(p =>
p.parentPath === pageData.path &&
!p.hide &&
p.showInSidebar !== false
);
// Filter subpages by RBAC access
const accessibleSubpages: GenericPageData[] = [];
for (const subpage of allSubpages) {
try {
const hasSubpageRBACAccess = await canView('UI', subpage.path);
if (!hasSubpageRBACAccess) {
continue;
}
if (subpage.privilegeChecker) {
try {
const hasPrivilege = await subpage.privilegeChecker();
if (!hasPrivilege) {
continue;
}
} catch (error) {
console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error);
continue;
}
}
accessibleSubpages.push(subpage);
} catch (error) {
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
}
}
if (accessibleSubpages.length > 0) {
// Create item with submenu (no link since it has subpages)
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: undefined, // No link - has subpages, so it's a navigation node
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0, // Top-level items have depth 0
submenu: accessibleSubpages.map(subpage => ({
id: subpage.id,
name: resolveLanguageText(subpage.name, t),
link: `/${subpage.path}`,
icon: subpage.icon,
depth: 1 // First level of submenu
}))
});
} else {
// No accessible subpages, show as regular item
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0 // Top-level items have depth 0
});
}
} else {
// Regular items without subpages
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0 // Top-level items have depth 0
});
}
}
// Sort all items by order
const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0));
return sortedItems;
};
// Refresh sidebar items
const refreshSidebar = async () => {
console.log('🔄 SidebarProvider: Refreshing sidebar items...');
setLoading(true);
setError(null);
try {
// Preload all UI permissions in a single API call
// This caches all permissions before iterating through pages
await preloadUiPermissions();
const items = await getSidebarItems();
console.log('✅ SidebarProvider: Setting sidebar items:', {
count: items.length,
items: items.map(item => ({ id: item.id, link: item.link, name: item.name }))
});
setSidebarItems(items);
} catch (err) {
console.error('❌ SidebarProvider: Error refreshing sidebar:', err);
setError(err instanceof Error ? err.message : t('Seitenleiste konnte nicht geladen werden'));
} finally {
setLoading(false);
}
};
// Load sidebar items on mount and when language changes
useEffect(() => {
refreshSidebar();
}, [t]);
const contextValue: SidebarContextType = {
sidebarItems,
loading,
error,
refreshSidebar
};
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
};
export default SidebarProvider;

View file

@ -1,27 +0,0 @@
/**
* PageManager data: allPageData and SidebarItem type.
*/
import type React from 'react';
export { allPageData } from './pages';
export interface SidebarItem {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
moduleEnabled?: boolean;
order?: number;
submenu?: SidebarSubmenuItemData[];
depth?: number;
}
export interface SidebarSubmenuItemData {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
depth?: number;
submenu?: SidebarSubmenuItemData[];
}

View file

@ -1,15 +0,0 @@
/**
* Central page registry: all PageData for PageManager/Sidebar.
*/
import type { GenericPageData } from '../../pageInterface';
import { trusteePositionDocumentsPageData } from './trustee/position-documents';
import { realEstatePages } from './realestate';
export { realEstatePages } from './realestate';
export { trusteePositionDocumentsPageData } from './trustee/position-documents';
export const allPageData: GenericPageData[] = [
trusteePositionDocumentsPageData,
...realEstatePages,
];

View file

@ -1,3 +0,0 @@
import { GenericPageData } from '../../../pageInterface';
export const realEstatePages: GenericPageData[] = [];

View file

@ -1,241 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaLink, FaPlus } from 'react-icons/fa';
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
return {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for position-documents data
const createPositionDocumentsHook = () => {
return () => {
const {
positionDocuments,
loading,
error,
refetch,
removeOptimistically,
attributes,
permissions,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteePositionDocuments();
const {
handlePositionDocumentDelete,
handlePositionDocumentCreate,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError
} = useTrusteePositionDocumentOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandlePositionDocumentCreate = useCallback(async (formData: any) => {
return await handlePositionDocumentCreate(formData);
}, [handlePositionDocumentCreate]);
const handleDeleteSingle = useCallback(async (positionDocument: any) => {
const success = await handlePositionDocumentDelete(positionDocument.id);
if (success) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedPositionDocuments: any[]) => {
const positionDocumentIds = selectedPositionDocuments.map(pd => pd.id);
const results = await Promise.all(
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
return {
data: positionDocuments,
loading,
error,
refetch,
removeOptimistically,
handleDelete: handlePositionDocumentDelete,
handleDeleteMultiple,
handlePositionDocumentCreate: wrappedHandlePositionDocumentCreate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError,
attributes,
permissions,
columns: generatedColumns,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteePositionDocumentsPageData: GenericPageData = {
id: 'administration-trustee-position-documents',
path: 'administration/trustee/position-documents',
name: 'trustee.positionDocuments.title',
description: 'trustee.positionDocuments.description',
// Parent page
parentPath: 'administration/trustee',
// Visual
icon: FaLink,
title: 'trustee.positionDocuments.title',
subtitle: 'trustee.positionDocuments.subtitle',
// Header buttons
headerButtons: [
{
id: 'new-position-document',
label: 'trustee.positionDocuments.new',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.positionDocuments.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'trustee.organisation',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Organisation is required';
}
return null;
}
},
{
key: 'contractId',
label: 'trustee.positionDocuments.field.contractId',
type: 'enum',
required: true,
optionsReference: 'trustee.contract',
dependsOn: 'organisationId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Contract is required';
}
return null;
}
},
{
key: 'positionId',
label: 'trustee.positionDocuments.field.positionId',
type: 'enum',
required: true,
optionsReference: 'trustee.position',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Position is required';
}
return null;
}
},
{
key: 'documentId',
label: 'trustee.positionDocuments.field.documentId',
type: 'enum',
required: true,
optionsReference: 'trustee.document',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Document is required';
}
return null;
}
}
],
popupTitle: 'trustee.positionDocuments.modal.create.title',
popupSize: 'medium',
createOperationName: 'handlePositionDocumentCreate',
successMessage: 'trustee.positionDocuments.create.success',
errorMessage: 'trustee.positionDocuments.create.error'
}
}
],
// Content sections
content: [
{
id: 'position-documents-table',
type: 'table',
tableConfig: {
hookFactory: createPositionDocumentsHook,
actionButtons: [
{
type: 'delete',
title: 'trustee.positionDocuments.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingPositionDocuments',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
return { disabled: !hasDelete, message: 'No permission to delete links' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'position-documents-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Position-Documents activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Position-Documents loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Position-Documents unloaded');
}
};

View file

@ -1,44 +0,0 @@
/**
* PageManager page interface and helpers.
* Used by PageData definitions and SidebarProvider.
*/
import type React from 'react';
export interface GenericPageData {
id: string;
path: string;
name: string;
description?: string;
parentPath?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
title?: string;
subtitle?: string;
headerButtons?: Array<Record<string, unknown>>;
content?: Array<Record<string, unknown>>;
moduleEnabled?: boolean;
order?: number;
hide?: boolean;
showInSidebar?: boolean;
showInSidebarIf?: boolean;
hasSubpages?: boolean;
privilegeChecker?: () => Promise<boolean> | boolean;
persistent?: boolean;
preload?: boolean;
preserveState?: boolean;
onActivate?: () => void | Promise<void>;
onLoad?: () => void | Promise<void>;
onUnload?: () => void | Promise<void>;
}
type TranslationFunction = (key: string, params?: Record<string, string | number | boolean | null | undefined>) => string;
/**
* Resolve display text from a page name (i18n key) via the translation function.
*/
export function resolveLanguageText(
name: string,
t: TranslationFunction
): string {
return t(name);
}

View file

@ -1,45 +1,109 @@
/** /**
* MainLayout * MainLayout
* *
* Hauptlayout der Anwendung mit Sidebar und Content-Bereich. * Hauptlayout der Anwendung mit Sidebar und Content-Bereich.
* Enthält den FeatureProvider für das Multi-Tenant-System. * Enthält den FeatureProvider für das Multi-Tenant-System.
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection'; import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge'; import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
import { isKeepAliveScoped } from '../types/keepAlive.types';
import styles from './MainLayout.module.css'; import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/; const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/; display: isVisible ? 'flex' : 'none',
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/; flexDirection: 'column',
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/; position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
});
const RoutedKeepAliveUnscoped: React.FC<{ entry: KeepAliveUnscopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
return (
<div style={keepAliveShellStyle(isVisible, true)}>
{entry.render()}
</div>
);
};
const RoutedKeepAliveScoped: React.FC<{ entry: KeepAliveScopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
const {
scopeRegex,
requireMandateForMount = true,
shellOverflowHidden = true,
render,
} = entry;
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = pathname.match(scopeRegex);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
const scopeReady = requireMandateForMount
? !!(mandateId && instanceId)
: !!instanceId;
if (!scopeReady) {
return null;
}
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div style={keepAliveShellStyle(isVisible, shellOverflowHidden)}>
{render({ mandateId, instanceId, scopeKey })}
</div>
);
};
const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string }> = ({
entry,
pathname,
}) => {
if (!isKeepAliveScoped(entry)) {
return <RoutedKeepAliveUnscoped entry={entry} pathname={pathname} />;
}
return <RoutedKeepAliveScoped entry={entry} pathname={pathname} />;
};
// ============================================================================= // =============================================================================
// INNER LAYOUT (mit Zugriff auf Store) // INNER LAYOUT (mit Zugriff auf Store)
// ============================================================================= // =============================================================================
const MainLayoutInner: React.FC = () => { const MainLayoutInner: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { loadFeatures, initialized, loading, error } = useFeatureStore(); const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation(); const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname); const hideOutletShell = hideFeatureOutlet(location.pathname);
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
const isGEEditorKeepAliveVisible = _GE_EDITOR_ROUTE_RE.test(location.pathname);
const isLanguagesKeepAliveVisible = _ADMIN_LANGUAGES_RE.test(location.pathname);
const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible || isGEEditorKeepAliveVisible || isLanguagesKeepAliveVisible;
// Features laden beim Mount // Features laden beim Mount
useEffect(() => { useEffect(() => {
if (!initialized && !loading) { if (!initialized && !loading) {
@ -61,7 +125,7 @@ const MainLayoutInner: React.FC = () => {
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
return ( return (
<div className={styles.mainLayout}> <div className={styles.mainLayout}>
{isMobileSidebarOpen && ( {isMobileSidebarOpen && (
@ -75,35 +139,25 @@ const MainLayoutInner: React.FC = () => {
{/* Sidebar */} {/* Sidebar */}
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}> <aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
<div className={styles.logoContainer}> <div className={styles.logoContainer}>
<img <img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
src="/logos/poweron-logo.png"
alt="PowerOn"
className={styles.logoImage}
/>
</div> </div>
<nav className={styles.navigation}> <nav className={styles.navigation}>
{loading && ( {loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
<div className={styles.loadingNav}>
{t('Lade Navigation…')}
</div>
)}
{error && ( {error && (
<div className={styles.errorNav}> <div className={styles.errorNav}>
{t('Fehler')}: {error} {t('Fehler')}: {error}
</div> </div>
)} )}
{initialized && !loading && ( {initialized && !loading && <MandateNavigation />}
<MandateNavigation />
)}
</nav> </nav>
{/* User-Bereich am unteren Rand */} {/* User-Bereich am unteren Rand */}
<UserSection /> <UserSection />
</aside> </aside>
{/* Content */} {/* Content */}
<main className={styles.content}> <main className={styles.content}>
<div className={styles.mobileTopBar}> <div className={styles.mobileTopBar}>
@ -114,17 +168,12 @@ const MainLayoutInner: React.FC = () => {
> >
</button> </button>
<img <img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
src="/logos/poweron-logo.png"
alt="PowerOn"
className={styles.mobileLogo}
/>
</div> </div>
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} /> {KEEP_ALIVE_ROUTES.map((routeEntry) => (
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} /> <RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} />
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} /> ))}
<AdminLanguagesKeepAlive isVisible={isLanguagesKeepAliveVisible} />
<div <div
className={styles.outletShell} className={styles.outletShell}

View file

@ -1177,6 +1177,13 @@ const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }>
); );
}; };
const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient';
/** Hide persisted transient extract JSON from user-facing Workspace file lists */
function _isHiddenWorkflowArtifactFile(f: { fileName?: string }): boolean {
return (f.fileName ?? '').toLowerCase().includes(_INTERNAL_EXTRACT_FILENAME_SUBSTR);
}
const _ProducedFilesSection: React.FC<{ const _ProducedFilesSection: React.FC<{
steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>; steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>;
unassignedFiles?: Array<{ id: string; fileName?: string }>; unassignedFiles?: Array<{ id: string; fileName?: string }>;
@ -1186,10 +1193,12 @@ const _ProducedFilesSection: React.FC<{
const allFiles: Array<{ id: string; fileName?: string }> = []; const allFiles: Array<{ id: string; fileName?: string }> = [];
for (const step of steps) { for (const step of steps) {
for (const f of step.outputFiles ?? []) { for (const f of step.outputFiles ?? []) {
if (_isHiddenWorkflowArtifactFile(f)) continue;
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
} }
} }
for (const f of unassignedFiles ?? []) { for (const f of unassignedFiles ?? []) {
if (_isHiddenWorkflowArtifactFile(f)) continue;
if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
} }
if (!allFiles.length) return null; if (!allFiles.length) return null;
@ -1312,8 +1321,8 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
{steps.map((step) => { {steps.map((step) => {
const inputData = _stripFileRefKeys(step.inputSnapshot ?? {}); const inputData = _stripFileRefKeys(step.inputSnapshot ?? {});
const outputData = _stripFileRefKeys(step.output ?? {}); const outputData = _stripFileRefKeys(step.output ?? {});
const inputFiles = step.inputFiles ?? []; const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
const outputFiles = step.outputFiles ?? []; const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
const hasInput = inputData !== undefined || inputFiles.length > 0; const hasInput = inputData !== undefined || inputFiles.length > 0;
const hasOutput = outputData !== undefined || outputFiles.length > 0; const hasOutput = outputData !== undefined || outputFiles.length > 0;
return ( return (
@ -1374,12 +1383,16 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
})} })}
</div> </div>
)} )}
{unassignedFiles && unassignedFiles.length > 0 && ( {(() => {
const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
if (!visibleUnassigned.length) return null;
return (
<> <>
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4> <h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
<_FileLinkList files={unassignedFiles} /> <_FileLinkList files={visibleUnassigned} />
</> </>
)} );
})()}
</div> </div>
); );
}; };

View file

@ -6,6 +6,8 @@
*/ */
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom';
import { hideFeatureOutlet } from '../config/keepAliveRoutes';
import { useCurrentInstance } from '../hooks/useCurrentInstance'; import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions'; import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
// Trustee Views // Trustee Views
@ -28,7 +30,6 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
// GraphicalEditor Views // GraphicalEditor Views
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage'; import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage'; import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage'; import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
// Workspace Views // Workspace Views
@ -147,7 +148,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
}, },
graphicalEditor: { graphicalEditor: {
editor: GraphicalEditorPage, editor: GraphicalEditorPage,
workflows: GraphicalEditorWorkflowsPage,
'workflows-tasks': GraphicalEditorWorkflowsTasksPage, 'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
templates: GraphicalEditorTemplatesPage, templates: GraphicalEditorTemplatesPage,
}, },
@ -190,6 +190,7 @@ interface FeatureViewPageProps {
} }
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => { export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const location = useLocation();
const { instance, featureCode, isValid } = useCurrentInstance(); const { instance, featureCode, isValid } = useCurrentInstance();
// Berechtigungs-Check // Berechtigungs-Check
@ -231,13 +232,9 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return null; return null;
} }
// CommCoach session is rendered persistently by CommcoachKeepAlive at MainLayout level. // Feature outlet is hidden for paths configured in KEEP_ALIVE_ROUTES (rendered in MainLayout).
if (featureCode === 'commcoach' && view === 'session') { // Add new persistent workspace URLs there if needed.
return null; if (hideFeatureOutlet(location.pathname)) {
}
// GraphicalEditor editor is rendered persistently by GraphicalEditorKeepAlive at MainLayout level.
if (featureCode === 'graphicalEditor' && view === 'editor') {
return null; return null;
} }

View file

@ -178,7 +178,7 @@ const StorePage: React.FC = () => {
)} )}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}> <span className={styles.bannerSeparator}>
{t('Testphase endet am')}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()} {t('Testphase endet am')}: {new Date(Number(subscriptionInfo.trialEndsAt) * 1000).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</span> </span>
)} )}
</div> </div>

View file

@ -1,16 +1,17 @@
/** /**
* AdminDatabaseHealthPage * AdminDatabaseHealthPage
* *
* SysAdmin-only page with two tabs: * SysAdmin-only page with three tabs:
* 1. Table Statistics pg_stat data for every table across all databases * 1. Table Statistics pg_stat data for every table across all databases
* 2. Orphan Cleanup FK orphan detection with per-relation + batch cleanup * 2. Orphan Cleanup FK orphan detection with per-relation + batch cleanup
* 3. Migration Database backup (export) and restore (import)
* *
* Both tabs use FormGeneratorTable with a client-side pagination/sort/filter * Both Stats/Orphan tabs use FormGeneratorTable with a client-side pagination/sort/filter
* adapter (the backend returns all rows at once; the dataset is small enough). * adapter (the backend returns all rows at once; the dataset is small enough).
*/ */
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload } from 'react-icons/fa'; import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload, FaUpload, FaDatabase, FaInfoCircle, FaCheckCircle } from 'react-icons/fa';
import api from '../../api'; import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
@ -721,6 +722,937 @@ const OrphansTab: React.FC = () => {
}; };
// ---------------------------------------------------------------------------
// Types (Migration)
// ---------------------------------------------------------------------------
interface MigrationDatabase {
name: string;
tableCount: number;
recordCount: number;
}
interface ValidationSummaryItem {
database: string;
tableCount: number;
recordCount: number;
registered: boolean;
}
interface ValidationResult {
valid: boolean;
summary: ValidationSummaryItem[];
warnings: string[];
systemObjectsFound: Array<{ type: string; label: string; payloadId: string }>;
}
interface ProgressLogEntry {
ts: string;
message: string;
status: 'info' | 'success' | 'error';
}
// ---------------------------------------------------------------------------
// MigrationTab
// ---------------------------------------------------------------------------
const MigrationTab: React.FC = () => {
const { t } = useLanguage();
const toast = useToast();
const { confirm, ConfirmDialog } = useConfirm();
// --- Backup state ---
const [databases, setDatabases] = useState<MigrationDatabase[]>([]);
const [selectedDbs, setSelectedDbs] = useState<Set<string>>(new Set());
const [loadingDbs, setLoadingDbs] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportLog, setExportLog] = useState<ProgressLogEntry[]>([]);
const [instanceLabel, setInstanceLabel] = useState('unknown');
// --- Restore state ---
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [importMode, setImportMode] = useState<'replace' | 'merge'>('merge');
const [importing, setImporting] = useState(false);
const [importLog, setImportLog] = useState<ProgressLogEntry[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const exportLogRef = useRef<HTMLDivElement>(null);
const importLogRef = useRef<HTMLDivElement>(null);
// --- Fetch databases ---
const _fetchDatabases = useCallback(async () => {
try {
setLoadingDbs(true);
const res = await api.get('/api/admin/database-health/migration/databases');
const dbs: MigrationDatabase[] = res.data.databases || [];
setDatabases(dbs);
setSelectedDbs(new Set(dbs.map(d => d.name)));
if (res.data.instanceLabel) setInstanceLabel(res.data.instanceLabel);
} catch {
setDatabases([]);
} finally {
setLoadingDbs(false);
}
}, []);
useEffect(() => { _fetchDatabases(); }, [_fetchDatabases]);
// --- Backup: DB selection ---
const allSelected = databases.length > 0 && selectedDbs.size === databases.length;
const _toggleAll = () => {
if (allSelected) {
setSelectedDbs(new Set());
} else {
setSelectedDbs(new Set(databases.map(d => d.name)));
}
};
const _toggleDb = (name: string) => {
setSelectedDbs(prev => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
// --- Backup: Export (per-DB with progress) ---
const _addExportLog = useCallback((message: string, logStatus: ProgressLogEntry['status'] = 'info') => {
const ts = new Date().toLocaleTimeString();
setExportLog(prev => [...prev, { ts, message, status: logStatus }]);
setTimeout(() => exportLogRef.current?.scrollTo({ top: exportLogRef.current.scrollHeight }), 50);
}, []);
const _startExport = async () => {
if (selectedDbs.size === 0) return;
setExporting(true);
setExportLog([]);
const dbList = Array.from(selectedDbs);
const isFullExport = allSelected;
const totalDbs = dbList.length;
_addExportLog(t('Export gestartet: {count} Datenbanken', { count: totalDbs }));
let token = '';
try {
const startRes = await api.post('/api/admin/database-health/migration/export-start');
token = startRes.data.token;
} catch (err: any) {
_addExportLog(t('Fehler beim Starten des Exports: {error}', { error: String(err) }), 'error');
toast.showError(t('Export fehlgeschlagen'));
setExporting(false);
return;
}
let totalTables = 0;
let totalRecords = 0;
let errors = 0;
let exportedCount = 0;
for (let i = 0; i < dbList.length; i++) {
const dbName = dbList[i];
_addExportLog(t('Exportiere {index}/{total}: {db}...', { index: i + 1, total: totalDbs, db: dbName }));
try {
const res = await api.get('/api/admin/database-health/migration/export-single', {
params: { token, database: dbName },
});
totalTables += res.data.tableCount || 0;
totalRecords += res.data.totalRecords || 0;
exportedCount++;
_addExportLog(
t('{db}: {tables} Tabellen, {records} Datensaetze', {
db: dbName, tables: res.data.tableCount || 0, records: res.data.totalRecords || 0,
}),
'success',
);
} catch (err: any) {
errors++;
const detail = err?.response?.data?.detail;
_addExportLog(
t('Fehler bei {db}: {error}', { db: dbName, error: typeof detail === 'string' ? detail : String(err) }),
'error',
);
}
}
if (exportedCount === 0) {
_addExportLog(t('Export abgebrochen: keine Daten exportiert'), 'error');
toast.showError(t('Export fehlgeschlagen'));
setExporting(false);
return;
}
_addExportLog(t('Erstelle Exportdatei...'));
try {
const ts = new Date().toISOString().replace(/:/g, '-').slice(0, 19);
const scope = isFullExport ? 'full' : 'partial';
const filename = `db_backup_${instanceLabel}_${scope}_${ts}.json`;
const res = await api.get('/api/admin/database-health/migration/export-download', {
params: { token, filename },
responseType: 'blob',
});
const blob = new Blob([res.data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
_addExportLog(
t('Export abgeschlossen: {dbs} Datenbanken, {tables} Tabellen, {records} Datensaetze', {
dbs: exportedCount, tables: totalTables, records: totalRecords,
}),
'success',
);
if (errors > 0) {
toast.showWarning(t('Export mit {count} Fehlern abgeschlossen', { count: errors }));
} else {
toast.showSuccess(t('Export erfolgreich'));
}
} catch (err: any) {
_addExportLog(t('Fehler beim Download der Exportdatei: {error}', { error: String(err) }), 'error');
toast.showError(t('Export fehlgeschlagen'));
} finally {
setExporting(false);
}
};
// --- Restore: File upload ---
const _handleFileSelect = (file: File) => {
setUploadedFile(file);
setValidation(null);
_validateFile(file);
};
const _onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) _handleFileSelect(file);
if (e.target) e.target.value = '';
};
const _onDrop = (e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (file && file.name.endsWith('.json')) {
_handleFileSelect(file);
}
};
const _onDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
// --- Restore: Validate (uploads file to server, streams to disk) ---
const importTokenRef = useRef('');
const _validateFile = async (file: File) => {
setValidating(true);
setValidation(null);
importTokenRef.current = '';
try {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/api/admin/database-health/migration/upload-import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 0,
});
importTokenRef.current = res.data.token || '';
setValidation({
valid: res.data.valid,
summary: (res.data.databases || []).map((d: any) => ({
database: d.database,
tableCount: d.tableCount,
recordCount: d.recordCount,
registered: true,
})),
warnings: res.data.warnings || [],
systemObjectsFound: res.data.systemObjectsFound || [],
});
} catch (err: any) {
const detail = err?.response?.data?.detail;
setValidation({
valid: false, summary: [], systemObjectsFound: [],
warnings: [typeof detail === 'string' ? detail : t('Upload oder Validierung fehlgeschlagen')],
});
} finally {
setValidating(false);
}
};
// --- Restore: Import (per-DB with progress) ---
const _addImportLog = useCallback((message: string, logStatus: ProgressLogEntry['status'] = 'info') => {
const ts = new Date().toLocaleTimeString();
setImportLog(prev => [...prev, { ts, message, status: logStatus }]);
setTimeout(() => importLogRef.current?.scrollTo({ top: importLogRef.current.scrollHeight }), 50);
}, []);
const _startImport = async () => {
if (!importTokenRef.current || !validation?.valid || importing) return;
setImporting(true);
const modeLabel = importMode === 'replace'
? t('Neu (Datenbank leeren und importieren)')
: t('Zusammenfuehren (bestehende Daten belassen, neue ergaenzen)');
const ok = await confirm(
t('Import mit Modus "{mode}" starten? Dieser Vorgang kann nicht rueckgaengig gemacht werden.', { mode: modeLabel }),
{ title: t('Import starten'), variant: importMode === 'replace' ? 'danger' : 'primary' },
);
if (!ok) { setImporting(false); return; }
setImportLog([]);
const token = importTokenRef.current;
const dbList = validation.summary.filter(s => s.registered);
const totalDbs = dbList.length;
let totalRecords = 0;
let errors = 0;
_addImportLog(t('Import gestartet: {count} Datenbanken', { count: totalDbs }));
for (let i = 0; i < dbList.length; i++) {
const dbInfo = dbList[i];
_addImportLog(
t('Importiere {index}/{total}: {db} ({records} Datensaetze)...', {
index: i + 1, total: totalDbs, db: dbInfo.database, records: dbInfo.recordCount,
}),
);
try {
const res = await api.post('/api/admin/database-health/migration/import-single', {
token,
database: dbInfo.database,
mode: importMode,
});
const result = res.data;
totalRecords += result.recordCount || 0;
const dbWarnings: string[] = result.warnings || [];
for (const w of dbWarnings) {
_addImportLog(t('Warnung: {msg}', { msg: w }), 'error');
}
_addImportLog(
t('{db}: {count} Datensaetze importiert', { db: dbInfo.database, count: result.recordCount || 0 }),
dbWarnings.length > 0 ? 'error' : 'success',
);
} catch (err: any) {
errors++;
const detail = err?.response?.data?.detail;
_addImportLog(
t('Fehler bei {db}: {error}', {
db: dbInfo.database,
error: typeof detail === 'string' ? detail : String(err),
}),
'error',
);
}
}
try { await api.post('/api/admin/database-health/migration/import-done', { token }); } catch { /* ignore */ }
_addImportLog(
t('Import abgeschlossen: {records} Datensaetze in {dbs} Datenbanken', {
records: totalRecords, dbs: totalDbs,
}),
'success',
);
if (errors > 0) {
toast.showWarning(t('{count} Datensaetze importiert, {errors} Fehler', { count: totalRecords, errors }));
} else {
toast.showSuccess(t('{count} Datensaetze erfolgreich importiert', { count: totalRecords }));
}
importTokenRef.current = '';
setImporting(false);
};
const _resetUpload = () => {
setUploadedFile(null);
setValidation(null);
importTokenRef.current = '';
};
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto', gap: '2rem', padding: '0.5rem 0' }}>
<ConfirmDialog />
{/* ---- BACKUP SECTION ---- */}
<section>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaDownload /> {t('Backup')}
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', margin: '0 0 0.75rem 0' }}>
{t('Datenbanken fuer Export auswaehlen')}
</p>
{loadingDbs ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
</div>
) : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem', marginBottom: '1rem' }}>
<label className={styles.checkboxLabel} style={{ fontWeight: 600 }}>
<input type="checkbox" checked={allSelected} onChange={_toggleAll} />
{t('Alle Datenbanken')}
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', paddingLeft: '0.5rem' }}>
{databases.map(db => (
<label key={db.name} className={styles.checkboxLabel}>
<input
type="checkbox"
checked={selectedDbs.has(db.name)}
onChange={() => _toggleDb(db.name)}
/>
<span>{db.name}</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
({db.tableCount} {t('Tabellen')}, ~{_formatNumber(db.recordCount)} {t('Zeilen')})
</span>
</label>
))}
</div>
</div>
<button
className={styles.primaryButton}
onClick={_startExport}
disabled={exporting || selectedDbs.size === 0}
>
{exporting ? (
<><FaSync className="spinning" /> {t('Export laeuft...')}</>
) : (
<><FaDownload /> {t('Export starten')}</>
)}
</button>
{exportLog.length > 0 && (
<div
ref={exportLogRef}
style={{
marginTop: '0.75rem',
maxHeight: '200px',
overflow: 'auto',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
>
{exportLog.map((entry, i) => (
<div key={i} style={{
color: entry.status === 'error' ? 'var(--danger-color, #e53e3e)'
: entry.status === 'success' ? '#388e3c' : 'var(--text-secondary)',
}}>
<span style={{ color: 'var(--text-tertiary)', marginRight: '0.5rem' }}>{entry.ts}</span>
{entry.message}
</div>
))}
</div>
)}
</>
)}
</section>
{/* ---- DIVIDER ---- */}
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: 0 }} />
{/* ---- RESTORE SECTION ---- */}
<section>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaUpload /> {t('Restore')}
</h2>
{/* File upload zone */}
{!uploadedFile ? (
<div
onDrop={_onDrop}
onDragOver={_onDragOver}
onClick={() => fileInputRef.current?.click()}
style={{
border: '2px dashed var(--border-color)',
borderRadius: '8px',
padding: '2rem',
textAlign: 'center',
cursor: 'pointer',
background: 'var(--bg-secondary)',
transition: 'border-color 0.2s',
}}
>
<FaUpload style={{ fontSize: '1.5rem', color: 'var(--text-tertiary)', marginBottom: '0.5rem' }} />
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{t('Datei hier ablegen oder klicken')}
</p>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{t('JSON-Datei hochladen')}
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={_onFileInputChange}
style={{ display: 'none' }}
/>
</div>
) : (
<div>
{/* File info */}
<div className={styles.infoBox} style={{ justifyContent: 'space-between' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaDatabase />
{uploadedFile.name} ({_formatBytes(uploadedFile.size)})
</span>
<button
className={styles.secondaryButton}
onClick={_resetUpload}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
>
{t('Andere Datei')}
</button>
</div>
{/* Validation */}
{validating && (
<div className={styles.loadingContainer} style={{ padding: '1rem' }}>
<div className={styles.spinner} />
<span>{t('Validierung laeuft...')}</span>
</div>
)}
{validation && !validating && (
<div style={{ marginTop: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.75rem 0' }}>
{t('Pruefung')}
</h3>
{/* Validation warnings */}
{validation.warnings.length > 0 && (
<div className={styles.infoBox} style={{
background: 'var(--warning-bg, #fffbeb)',
borderColor: 'var(--warning-color, #d69e2e)',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '0.25rem',
}}>
{validation.warnings.map((w, i) => (
<span key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaExclamationTriangle style={{ color: 'var(--warning-color, #d69e2e)', flexShrink: 0 }} /> {w}
</span>
))}
</div>
)}
{/* Summary table */}
{validation.summary.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem', marginBottom: '1rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>{t('Datenbank')}</th>
<th style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{t('Tabellen')}</th>
<th style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{t('Datensaetze')}</th>
<th style={{ textAlign: 'center', padding: '0.5rem 0.75rem' }}>{t('Status')}</th>
</tr>
</thead>
<tbody>
{validation.summary.map(s => (
<tr key={s.database} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.5rem 0.75rem' }}>{s.database}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{s.tableCount}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{_formatNumber(s.recordCount)}</td>
<td style={{ textAlign: 'center', padding: '0.5rem 0.75rem' }}>
{s.registered ? (
<FaCheckCircle style={{ color: '#388e3c' }} />
) : (
<FaExclamationTriangle style={{ color: 'var(--warning-color, #d69e2e)' }} />
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* System objects info */}
{validation.systemObjectsFound.length > 0 && (
<div className={styles.infoBox} style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '0.25rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 500 }}>
<FaInfoCircle style={{ color: 'var(--primary-color, #f25843)' }} />
{t('Systemdaten werden beim Import nicht geloescht')}
</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)', paddingLeft: '1.5rem' }}>
{validation.systemObjectsFound.map(o => o.label).join(', ')}
</span>
</div>
)}
{/* Import settings */}
{validation.valid && (
<div style={{ marginTop: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.75rem 0' }}>
{t('Import-Einstellungen')}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
<label className={styles.checkboxLabel} style={{ cursor: 'pointer' }}>
<input
type="radio"
name="importMode"
checked={importMode === 'merge'}
onChange={() => setImportMode('merge')}
/>
<div>
<div style={{ fontWeight: 500 }}>{t('Zusammenfuehren (bestehende Daten belassen, neue ergaenzen)')}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{t('Bestehende Datensaetze bleiben erhalten, nur fehlende werden eingefuegt')}
</div>
</div>
</label>
<label className={styles.checkboxLabel} style={{ cursor: 'pointer' }}>
<input
type="radio"
name="importMode"
checked={importMode === 'replace'}
onChange={() => setImportMode('replace')}
/>
<div>
<div style={{ fontWeight: 500 }}>{t('Neu (Datenbank leeren und importieren)')}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{t('Bestehende Daten werden geloescht und durch importierte Daten ersetzt')}
</div>
</div>
</label>
</div>
{importMode === 'replace' && (
<div className={styles.infoBox} style={{
background: 'var(--danger-bg, #fff5f5)',
borderColor: 'var(--danger-color, #e53e3e)',
}}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--danger-color, #e53e3e)', flexShrink: 0 }} />
{t('Achtung: Bestehende Daten werden unwiderruflich geloescht. Erstellen Sie zuerst ein Backup.')}
</div>
)}
<button
className={importMode === 'replace' ? styles.dangerButton : styles.primaryButton}
onClick={_startImport}
disabled={importing}
style={{ marginTop: '0.5rem' }}
>
{importing ? (
<><FaSync className="spinning" /> {t('Import laeuft...')}</>
) : (
<><FaUpload /> {t('Import starten')}</>
)}
</button>
{importLog.length > 0 && (
<div
ref={importLogRef}
style={{
marginTop: '0.75rem',
maxHeight: '200px',
overflow: 'auto',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
>
{importLog.map((entry, i) => (
<div key={i} style={{
color: entry.status === 'error' ? 'var(--danger-color, #e53e3e)'
: entry.status === 'success' ? '#388e3c' : 'var(--text-secondary)',
}}>
<span style={{ color: 'var(--text-tertiary)', marginRight: '0.5rem' }}>{entry.ts}</span>
{entry.message}
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</section>
</div>
);
};
// ---------------------------------------------------------------------------
// LegacyCleanupTab
// ---------------------------------------------------------------------------
interface LegacyTable {
id: string;
db: string;
table: string;
rowCount: number;
sizeBytes: number;
}
const LegacyCleanupTab: React.FC = () => {
const { t } = useLanguage();
const toast = useToast();
const { confirm, ConfirmDialog } = useConfirm();
const [allLegacy, setAllLegacy] = useState<LegacyTable[]>([]);
const [loading, setLoading] = useState(false);
const [dropping, setDropping] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [droppingBatch, setDroppingBatch] = useState(false);
const _fetchLegacy = useCallback(async () => {
try {
setLoading(true);
const res = await api.get('/api/admin/database-health/legacy-tables');
const rows: LegacyTable[] = (res.data.legacyTables || []).map((t: any) => ({
...t,
id: `${t.db}.${t.table}`,
}));
setAllLegacy(rows);
setSelected(new Set());
} catch {
setAllLegacy([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { _fetchLegacy(); }, [_fetchLegacy]);
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allLegacy);
const databases = useMemo(
() => Array.from(new Set(allLegacy.map(l => l.db))).sort(),
[allLegacy],
);
const totals = useMemo(() => {
let rows = 0, size = 0;
for (const l of allLegacy) { rows += l.rowCount; size += l.sizeBytes; }
return { count: allLegacy.length, rows, size, dbs: databases.length };
}, [allLegacy, databases]);
const _dropOne = async (entry: LegacyTable) => {
const ok = await confirm(
t('Legacy-Tabelle {db}.{table} ({rows} Zeilen, {size}) unwiderruflich löschen?', {
db: entry.db, table: entry.table, rows: _formatNumber(entry.rowCount), size: _formatBytes(entry.sizeBytes),
}),
{ title: t('Legacy-Tabelle löschen'), variant: 'danger' },
);
if (!ok) return;
setDropping(entry.id);
try {
await api.post('/api/admin/database-health/legacy-tables/drop', { db: entry.db, table: entry.table });
toast.showSuccess(t('{db}.{table} gelöscht', { db: entry.db, table: entry.table }));
_fetchLegacy();
} catch (err: any) {
const detail = err?.response?.data?.detail;
toast.showError(typeof detail === 'string' ? detail : t('Fehler beim Löschen'));
} finally {
setDropping(null);
}
};
const _dropSelected = async () => {
if (selected.size === 0) return;
const selectedEntries = allLegacy.filter(l => selected.has(l.id));
const totalRows = selectedEntries.reduce((s, l) => s + l.rowCount, 0);
const ok = await confirm(
t('{count} Legacy-Tabellen mit insgesamt {rows} Zeilen unwiderruflich löschen?', {
count: selected.size, rows: _formatNumber(totalRows),
}),
{ title: t('Ausgewählte löschen'), variant: 'danger' },
);
if (!ok) return;
setDroppingBatch(true);
let deleted = 0;
let errors = 0;
for (const entry of selectedEntries) {
try {
await api.post('/api/admin/database-health/legacy-tables/drop', { db: entry.db, table: entry.table });
deleted++;
} catch {
errors++;
}
}
if (errors > 0) {
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted, errors }));
} else {
toast.showSuccess(t('{deleted} Legacy-Tabellen gelöscht', { deleted }));
}
setDroppingBatch(false);
_fetchLegacy();
};
const _toggleSelect = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const _toggleAll = () => {
if (selected.size === allLegacy.length) {
setSelected(new Set());
} else {
setSelected(new Set(allLegacy.map(l => l.id)));
}
};
const columns: ColumnConfig[] = useMemo(() => [
{
key: '_select',
label: '',
width: 40,
formatter: (_val: any, row: LegacyTable) => (
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => _toggleSelect(row.id)}
/>
),
},
{
key: 'db',
label: t('Datenbank'),
sortable: true,
filterable: true,
searchable: true,
width: 200,
filterOptions: databases,
},
{
key: 'table',
label: t('Tabelle'),
sortable: true,
searchable: true,
width: 250,
formatter: (v: string) => (
<span style={{ color: 'var(--danger-color, #e53e3e)' }}>
<code>{v}</code>
<span style={{
marginLeft: '0.4rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
fontSize: '0.625rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
background: 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
color: 'var(--primary-color, #f25843)',
}}>
{t('kein Modell')}
</span>
</span>
),
},
{
key: 'rowCount',
label: t('Zeilen (ca.)'),
type: 'number' as const,
sortable: true,
width: 120,
formatter: (v: number) => _formatNumber(v),
},
{
key: 'sizeBytes',
label: t('Grösse'),
type: 'number' as const,
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
], [t, databases, selected]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ConfirmDialog />
<div className={styles.filterSection}>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
</button>
<label className={styles.checkboxLabel} style={{ fontWeight: 600 }}>
<input type="checkbox" checked={selected.size === allLegacy.length && allLegacy.length > 0} onChange={_toggleAll} />
{t('Alle')}
</label>
{selected.size > 0 && (
<button className={styles.dangerButton} onClick={_dropSelected} disabled={droppingBatch || loading}>
<FaTrashAlt className={droppingBatch ? 'spinning' : ''} />
{t('Ausgewählte löschen')} ({selected.size})
</button>
)}
</div>
</div>
{allLegacy.length > 0 && (
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{t('{count} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', {
count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size),
})}
</div>
)}
<div className={styles.tableContainer}>
<FormGeneratorTable
data={visibleData}
columns={columns}
loading={loading}
sortable={true}
searchable={true}
filterable={true}
pagination={true}
pageSize={50}
selectable={false}
customActions={[
{
id: 'drop',
icon: <FaTrashAlt />,
onClick: (row: LegacyTable) => _dropOne(row),
loading: (row: LegacyTable) => dropping === row.id || droppingBatch,
title: t('Tabelle löschen'),
},
]}
hookData={{
refetch,
pagination,
fetchFilterValues,
}}
emptyMessage={t('Keine Legacy-Tabellen gefunden')}
/>
</div>
</div>
);
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Page // Page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -739,6 +1671,16 @@ export const AdminDatabaseHealthPage: React.FC = () => {
label: t('Orphan Cleanup'), label: t('Orphan Cleanup'),
content: <OrphansTab />, content: <OrphansTab />,
}, },
{
id: 'legacy',
label: t('Legacy Cleanup'),
content: <LegacyCleanupTab />,
},
{
id: 'migration',
label: t('Migration'),
content: <MigrationTab />,
},
], [t]); ], [t]);
return ( return (
@ -746,7 +1688,7 @@ export const AdminDatabaseHealthPage: React.FC = () => {
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1> <h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken und verwaiste Datensätze')}</p> <p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
</div> </div>
</div> </div>

View file

@ -1,35 +0,0 @@
/**
* AdminLanguagesKeepAlive
*
* Keeps the AdminLanguagesPage mounted across route changes so that
* long-running AI translation progress, table state, and selections
* survive when the user navigates away and returns.
*/
import React from 'react';
import { AdminLanguagesPage } from './AdminLanguagesPage';
interface AdminLanguagesKeepAliveProps {
isVisible: boolean;
}
export const AdminLanguagesKeepAlive: React.FC<AdminLanguagesKeepAliveProps> = ({ isVisible }) => {
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<AdminLanguagesPage />
</div>
);
};
export default AdminLanguagesKeepAlive;

View file

@ -23,12 +23,16 @@ import { useLanguage } from '../../providers/language/LanguageContext';
const _formatCurrency = (amount: number) => const _formatCurrency = (amount: number) =>
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
const _formatDate = (iso: string | null | undefined): string => { const _formatDate = (value: string | number | null | undefined): string => {
if (!iso) return '—'; if (value == null || value === '') return '—';
try { try {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }); const num = typeof value === 'number' ? value : Number(value);
const ms = !isNaN(num) && num < 1e12 ? num * 1000 : num;
const d = !isNaN(ms) ? new Date(ms) : new Date(value);
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { } catch {
return iso; return String(value);
} }
}; };

View file

@ -1,55 +0,0 @@
/**
* CommcoachKeepAlive
*
* Keeps the CommCoach session page mounted across route changes.
* The voice session must persist when the user navigates to other tabs.
* Only the "session" tab is kept alive; modules/dashboard can unmount freely.
*
* Persistence is scoped per `(mandateId, instanceId)` switching to a
* different mandate or instance unmounts the previous view.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { CommcoachSessionView } from './CommcoachSessionView';
const _COMMCOACH_SESSION_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/;
interface CommcoachKeepAliveProps {
isVisible: boolean;
}
export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_COMMCOACH_SESSION_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
if (!mandateId || !instanceId) return null;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
}}
>
<CommcoachSessionView key={scopeKey} />
</div>
);
};
export default CommcoachKeepAlive;

View file

@ -276,6 +276,46 @@
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
} }
.taskCardDismissable {
position: relative;
padding-top: 0.85rem;
padding-right: 2.25rem;
}
.dismissOpenTaskBtn {
position: absolute;
top: 0.35rem;
right: 0.35rem;
width: 1.85rem;
height: 1.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-secondary, #888);
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.dismissOpenTaskBtn:hover:not(:disabled) {
color: var(--danger-color, #c82333);
background: rgba(220, 53, 69, 0.08);
}
.dismissOpenTaskBtn:focus-visible {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 2px;
}
.dismissOpenTaskBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.taskCard:last-child { .taskCard:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -396,6 +436,13 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Override broad .taskCard button[type='button'] primary styling for dismiss control */
.taskCard button.dismissOpenTaskBtn {
background: transparent;
color: var(--text-secondary, #888);
padding: 0;
}
/* Upload task */ /* Upload task */
.uploadTaskBlock { .uploadTaskBlock {
display: flex; display: flex;

View file

@ -1,96 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Persistence is per (mandateId, instanceId): switching to a different mandate
// or instance must remount the editor page so its internal state (loaded
// workflow, currentWorkflowId, …) is reset and saves go to the right tenant.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { MemoryRouter, useNavigate } from 'react-router-dom';
const _mountCount = { value: 0 };
vi.mock('./GraphicalEditorPage', () => ({
GraphicalEditorPage: ({ persistentMandateId, persistentInstanceId }: { persistentMandateId?: string; persistentInstanceId?: string }) => {
React.useEffect(() => {
_mountCount.value += 1;
}, []);
return <div data-testid="ge-page">{persistentMandateId}::{persistentInstanceId}</div>;
},
}));
import { GraphicalEditorKeepAlive } from './GraphicalEditorKeepAlive';
let _navigateTo: ((path: string) => void) | null = null;
const _NavCapture: React.FC = () => {
_navigateTo = useNavigate();
return null;
};
function _renderHarness(initialPath: string) {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<_NavCapture />
<GraphicalEditorKeepAlive isVisible />
</MemoryRouter>,
);
}
function _navigate(path: string) {
act(() => {
_navigateTo?.(path);
});
}
describe('GraphicalEditorKeepAlive — persistence per (mandate, instance)', () => {
it('remounts the page when the mandate changes', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA');
_navigate('/mandates/mB/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(2);
expect(screen.getByTestId('ge-page').textContent).toBe('mB::iA');
});
it('remounts the page when the instance changes', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
_navigate('/mandates/mA/graphicalEditor/iZ/editor');
expect(_mountCount.value).toBe(2);
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iZ');
});
it('does NOT remount when the route stays on the same (mandate, instance)', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
_navigate('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
});
it('keeps the cached page mounted (no remount) when the user navigates AWAY and BACK to the same scope', () => {
_mountCount.value = 0;
_renderHarness('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
// Away to a non-editor route: the regex match fails, refs keep their
// previous values — the cached page must not remount.
_navigate('/admin/languages');
expect(_mountCount.value).toBe(1);
expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA');
// Back to the same (mandate, instance) — still no remount.
_navigate('/mandates/mA/graphicalEditor/iA/editor');
expect(_mountCount.value).toBe(1);
});
});

View file

@ -1,70 +0,0 @@
/**
* GraphicalEditorKeepAlive
*
* Keeps the GraphicalEditorPage mounted across route changes so the canvas
* state, SSE connections, and editor context survive navigation to ANY page
* (other features, admin, settings, etc.).
*
* Persistence is scoped per `(mandateId, instanceId)`: when the user switches
* to a DIFFERENT mandate or instance via the navigator, the previous editor
* mount is discarded and a fresh page is mounted. Otherwise stale state from
* mandate A leaks into mandate B and saves end up hitting the wrong tenant
* (HTTP 404 / "not found").
*
* Implementation: feeds the cached `(mandate, instance)` tuple into both
* `props` and `key`. React reuses the mount as long as the tuple stays
* identical and unmounts/remounts on change.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { GraphicalEditorPage } from './GraphicalEditorPage';
const _GE_EDITOR_ROUTE_RE = /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/;
interface GraphicalEditorKeepAliveProps {
isVisible: boolean;
}
export const GraphicalEditorKeepAlive: React.FC<GraphicalEditorKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const hasEverMountedRef = useRef(false);
const match = location.pathname.match(_GE_EDITOR_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
hasEverMountedRef.current = true;
}
if (!hasEverMountedRef.current) return null;
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<GraphicalEditorPage
key={scopeKey}
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
</div>
);
};
export default GraphicalEditorKeepAlive;

View file

@ -1,441 +0,0 @@
/**
* GraphicalEditorWorkflowsPage
* List of saved workflows with FormGeneratorTable.
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
* Filter: Alle | Aktiv | Inaktiv.
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchWorkflows,
deleteWorkflow,
executeGraph,
updateWorkflow,
importWorkflowFromFile,
exportWorkflowToFile,
isWorkflowFileContent,
workflowFileNameFor,
WORKFLOW_FILE_EXTENSION,
type Automation2Workflow,
type WorkflowFileEnvelope,
} from '../../../api/workflowApi';
import { fetchAttributes } from '../../../api/attributesApi';
import type { AttributeDefinition } from '../../../api/attributesApi';
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
function formatTs(ts?: number): string {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
const { time } = formatUnixTimestamp(sec, undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return time;
}
export const GraphicalEditorWorkflowsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { mandateId } = useParams<{ mandateId: string }>();
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [importing, setImporting] = useState(false);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
useEffect(() => {
fetchAttributes(request, 'Automation2WorkflowView')
.then(setBackendAttributes)
.catch((err) => {
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
});
}, [request]);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
setLoading(true);
try {
const active = activeFilter === 'active' ? true : activeFilter === 'inactive' ? false : undefined;
const result = await fetchWorkflows(request, instanceId, { active, pagination: paginationParams });
if (result && typeof result === 'object' && 'items' in result && !Array.isArray(result)) {
setWorkflows((result as any).items);
setPaginationMeta((result as any).pagination);
} else {
setWorkflows(result as Automation2Workflow[]);
setPaginationMeta(null);
}
} catch (e) {
console.error('[graphicalEditor] load workflows failed', e);
showError(t('Fehler beim Laden der Workflows'));
} finally {
setLoading(false);
}
}, [instanceId, request, showError, activeFilter, t]);
useEffect(() => {
load();
}, [load]);
const handleDelete = useCallback(
async (workflowId: string): Promise<boolean> => {
if (!instanceId) return false;
try {
await deleteWorkflow(request, instanceId, workflowId);
showSuccess(t('Workflow gelöscht'));
await load();
return true;
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
return false;
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleEdit = useCallback(
(row: Automation2Workflow) => {
if (!mandateId || !instanceId) return;
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
},
[mandateId, instanceId, navigate]
);
const hasManualTrigger = useCallback((row: Automation2Workflow): boolean => {
const invs = row.invocations || [];
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
}, []);
const handleToggleActive = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const next = !(row.active !== false);
setTogglingId(row.id);
try {
await updateWorkflow(request, instanceId, row.id, { active: next });
showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') }));
} finally {
setTogglingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleRename = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const newLabel = await promptInput(t('Neuer Name:'), {
title: t('Workflow umbenennen'),
defaultValue: row.label,
placeholder: t('Workflow-Name'),
});
if (!newLabel || newLabel.trim() === row.label) return;
try {
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
showSuccess(t('Workflow umbenannt'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
}
},
[instanceId, request, promptInput, showSuccess, showError, load, t]
);
const handleExecute = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
setExecutingId(row.id);
try {
const invs = row.invocations || [];
const primary =
invs.find((i) => i.enabled && i.kind === 'manual') ||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
const result = await executeGraph(request, instanceId, row.graph!, row.id, {
...(primary ? { entryPointId: primary.id } : {}),
});
if (result?.success) {
if (result?.paused) {
showSuccess(t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.'));
} else {
showSuccess(t('Workflow ausgeführt'));
}
await load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
} finally {
setExecutingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleExport = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
try {
const result = await exportWorkflowToFile(request, instanceId, row.id, false);
const fileName = result.fileName || workflowFileNameFor(row.label);
const blob = new Blob([JSON.stringify(result.envelope, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('Workflow als Datei exportiert'));
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') }));
}
},
[instanceId, request, showSuccess, showError, t],
);
const handleImportFileSelected = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !instanceId) return;
setImporting(true);
try {
const text = await file.text();
let envelope: WorkflowFileEnvelope;
try {
envelope = JSON.parse(text) as WorkflowFileEnvelope;
} catch {
showError(t('Datei ist kein gültiges JSON'));
return;
}
if (!isWorkflowFileContent(envelope)) {
showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION }));
return;
}
const result = await importWorkflowFromFile(request, instanceId, { envelope });
const warnings = result?.warnings ?? [];
if (warnings.length > 0) {
showSuccess(
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }),
);
} else {
showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.'));
}
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') }));
} finally {
setImporting(false);
}
},
[instanceId, request, showSuccess, showError, load, t],
);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
{ key: 'active', width: 80, sortable: true, filterable: true },
{ key: 'isRunning', width: 80, sortable: true, filterable: true },
{
key: 'stuckAtNodeLabel',
label: t('steht bei'),
width: 160,
sortable: false,
filterable: false,
formatter: (value: string, row: Automation2Workflow) =>
row.isRunning && (value || row.stuckAtNodeId)
? value || row.stuckAtNodeId || '—'
: '—',
},
{
key: 'sysCreatedAt',
label: t('Erstellt'),
width: 140,
sortable: true,
filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'lastStartedAt',
label: t('zuletzt gestartet'),
width: 160,
sortable: true,
filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
width: 80,
sortable: true,
filterable: true,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
const hookData = {
refetch: load,
handleDelete: (id: string) => handleDelete(id),
pagination: paginationMeta,
};
if (!instanceId) {
return (
<div className={styles.adminPage}>
<p>{t('Keine Feature-Instanz gefunden')}</p>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>
{t('Workflows verwalten, ausführen und bearbeiten')}
</p>
</div>
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
{(['all', 'active', 'inactive'] as const).map((f) => (
<button
key={f}
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveFilter(f)}
disabled={loading}
>
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
</button>
))}
</div>
<button
className={styles.secondaryButton}
onClick={() => importFileInputRef.current?.click()}
disabled={importing || loading}
title={t('Workflow aus Datei importieren ({ext})', { ext: WORKFLOW_FILE_EXTENSION })}
>
<FaFileImport /> {importing ? t('Importiere...') : t('Importieren')}
</button>
<input
ref={importFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportFileSelected}
/>
<button
className={styles.secondaryButton}
onClick={() => load()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<Automation2Workflow>
data={workflows}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
apiEndpoint={`/api/workflows/${instanceId}/workflows`}
actionButtons={[
{
type: 'edit',
title: t('bearbeiten'),
onAction: handleEdit,
},
{
type: 'delete',
title: t('löschen'),
},
]}
customActions={[
{
id: 'rename',
icon: <FaPen />,
title: t('umbenennen'),
onClick: (row) => handleRename(row),
},
{
id: 'activate',
icon: <FaCheck />,
title: t('aktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active === false,
},
{
id: 'deactivate',
icon: <FaBan />,
title: t('deaktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active !== false,
},
{
id: 'execute',
icon: <FaPlay />,
title: t('ausführen'),
onClick: (row) => handleExecute(row),
loading: (row) => executingId === row.id,
visible: (row) => hasManualTrigger(row),
},
{
id: 'export',
icon: <FaFileExport />,
title: t('Als Datei exportieren'),
onClick: (row) => handleExport(row),
},
]}
onDelete={(row) => handleDelete(row.id)}
hookData={hookData}
emptyMessage={t('Keine Workflows gefunden. Erstelle einen.')}
/>
</div>
<PromptDialog />
</div>
);
};

View file

@ -7,26 +7,30 @@
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaUpload } from 'react-icons/fa'; import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaTimes, FaUpload } from 'react-icons/fa';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { import {
fetchTasks, fetchTasks,
cancelPendingTaskStopRun,
completeTask, completeTask,
fetchCompletedRuns, fetchCompletedRuns,
fetchWorkflows, fetchWorkflows,
executeGraph, executeGraph,
loadClickupListTasksForDropdown,
type Automation2Task, type Automation2Task,
type Automation2Workflow, type Automation2Workflow,
type CompletedRun, type CompletedRun,
type ApiRequestFunction,
} from '../../../api/workflowApi'; } from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { Popup } from '../../../components/UiComponents/Popup'; import { Popup } from '../../../components/UiComponents/Popup';
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor'; import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
import { useFileOperations } from '../../../hooks/useFiles'; import { useFileOperations } from '../../../hooks/useFiles';
import styles from './Automation2WorkflowsTasks.module.css'; import styles from './Automation2WorkflowsTasks.module.css';
import {
WorkflowRuntimeFormFields,
useWorkflowRuntimeFormRequiredOk,
type WorkflowRuntimeFormFieldRow,
} from '../../../components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -75,17 +79,38 @@ function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
} }
/** /**
* Primary entry for execute matches GraphicalEditorWorkflowsPage.handleExecute * Primary entry for execute align with first start node in graph order (backend-driven),
* (manual first, then form or api). * then fall back to manual / form / api on invocations list.
*/ */
function getPrimaryEntryPoint(wf: Automation2Workflow) { function getPrimaryEntryPoint(wf: Automation2Workflow) {
const invs = wf.invocations || []; const invs = wf.invocations || [];
const nodes = wf.graph?.nodes ?? [];
for (const n of nodes) {
const nodeType = n.type;
if (typeof nodeType === 'string' && nodeType.startsWith('trigger.')) {
const inv = invs.find((i) => i.enabled !== false && i.id === n.id);
if (inv) return inv;
}
}
return ( return (
invs.find((i) => i.enabled !== false && i.kind === 'manual') || invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api')) invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api'))
); );
} }
/** Form field rows from graph trigger.form for workflow list (parameters.formFields). */
function getTriggerFormFieldsForWorkflow(wf: Automation2Workflow): WorkflowRuntimeFormFieldRow[] {
const primary = getPrimaryEntryPoint(wf);
if (!primary || primary.kind !== 'form') return [];
const nodes = wf.graph?.nodes ?? [];
let node = nodes.find((n) => n.id === primary.id && n.type === 'trigger.form');
if (!node) node = nodes.find((n) => n.type === 'trigger.form');
if (!node) return [];
const raw = (node.parameters as Record<string, unknown> | undefined)?.formFields;
if (!Array.isArray(raw)) return [];
return raw as WorkflowRuntimeFormFieldRow[];
}
function primaryKindLabel(kind: string): string { function primaryKindLabel(kind: string): string {
if (kind === 'form') return 'Formular'; if (kind === 'form') return 'Formular';
if (kind === 'manual') return 'Manuell'; if (kind === 'manual') return 'Manuell';
@ -105,7 +130,11 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
const [completedExpanded, setCompletedExpanded] = useState(false); const [completedExpanded, setCompletedExpanded] = useState(false);
const [outputExpanded, setOutputExpanded] = useState(true); const [outputExpanded, setOutputExpanded] = useState(true);
const [submitting, setSubmitting] = useState<string | null>(null); const [submitting, setSubmitting] = useState<string | null>(null);
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null); const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [formStartWorkflow, setFormStartWorkflow] = useState<Automation2Workflow | null>(null);
const [formStartFields, setFormStartFields] = useState<WorkflowRuntimeFormFieldRow[]>([]);
const [startFormData, setStartFormData] = useState<Record<string, unknown>>({});
const load = useCallback(async () => { const load = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
@ -157,10 +186,37 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
} }
}; };
const handleDismissOpenTask = async (taskId: string) => {
if (!instanceId) return;
setDismissingTaskId(taskId);
try {
const res = await cancelPendingTaskStopRun(request, instanceId, taskId);
if (res.success) {
showSuccess(t('Ausführung abgebrochen'));
await load();
} else {
showError(t('Abbrechen fehlgeschlagen'));
}
} catch (e: unknown) {
const msg =
(e as { message?: string })?.message ?? t('Abbrechen fehlgeschlagen');
showError(msg);
console.error('[graphicalEditor] cancel task failed', e);
} finally {
setDismissingTaskId(null);
}
};
const handleStartWorkflow = useCallback( const handleStartWorkflow = useCallback(
async (wf: Automation2Workflow) => { async (wf: Automation2Workflow) => {
if (!instanceId || !wf.graph) return; if (!instanceId || !wf.graph) return;
const primary = getPrimaryEntryPoint(wf); const primary = getPrimaryEntryPoint(wf);
if (primary?.kind === 'form') {
setFormStartFields(getTriggerFormFieldsForWorkflow(wf));
setStartFormData({});
setFormStartWorkflow(wf);
return;
}
setExecutingWorkflowId(wf.id); setExecutingWorkflowId(wf.id);
try { try {
const result = await executeGraph(request, instanceId, wf.graph, wf.id, { const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
@ -187,6 +243,48 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
[instanceId, request, showSuccess, showError, load, t] [instanceId, request, showSuccess, showError, load, t]
); );
const formStartRequiredOk = useWorkflowRuntimeFormRequiredOk(formStartFields, startFormData);
const handleFormStartSubmit = useCallback(async () => {
if (!instanceId || !formStartWorkflow?.graph) return;
const wf = formStartWorkflow;
const primary = getPrimaryEntryPoint(wf);
const payload = { ...startFormData };
setExecutingWorkflowId(wf.id);
try {
const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
...(primary ? { entryPointId: primary.id } : {}),
payload,
});
if (result?.success) {
if (result?.paused) {
showSuccess(t('Workflow gestartet und bei Human Task pausiert.'));
} else {
showSuccess(t('Workflow gestartet'));
}
await load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: unknown) {
const msg =
(e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen');
showError(msg);
} finally {
setExecutingWorkflowId(null);
setFormStartWorkflow(null);
}
}, [
instanceId,
formStartWorkflow,
startFormData,
request,
showSuccess,
showError,
load,
t,
]);
const openTasks = tasks.filter((task) => task.status === 'pending'); const openTasks = tasks.filter((task) => task.status === 'pending');
const completedTasks = tasks.filter((task) => task.status !== 'pending'); const completedTasks = tasks.filter((task) => task.status !== 'pending');
@ -228,6 +326,9 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
instanceId={instanceId ?? undefined} instanceId={instanceId ?? undefined}
onSubmit={(result) => handleComplete(task.id, result)} onSubmit={(result) => handleComplete(task.id, result)}
submitting={submitting === task.id} submitting={submitting === task.id}
showDismiss
onDismiss={() => handleDismissOpenTask(task.id)}
dismissing={dismissingTaskId === task.id}
/> />
))} ))}
</div> </div>
@ -337,6 +438,41 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
)} )}
</div> </div>
</aside> </aside>
<Popup
isOpen={formStartWorkflow != null}
title={t('Formular ausfüllen')}
onClose={() => setFormStartWorkflow(null)}
closable={
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
closeOnEscape={
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
size="medium"
footerContent={
<button
type="button"
onClick={() => void handleFormStartSubmit()}
disabled={
!formStartRequiredOk ||
(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
}
className={styles.popupSubmitButton}
>
{formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id
? t('wird gesendet')
: t('absenden')}
</button>
}
>
<WorkflowRuntimeFormFields
fields={formStartFields}
formData={startFormData}
setFormData={setStartFormData}
formFieldsClassName={styles.formFields}
/>
</Popup>
</div> </div>
); );
}; };
@ -406,99 +542,10 @@ interface TaskCardProps {
onSubmit: (result: Record<string, unknown>) => void; onSubmit: (result: Record<string, unknown>) => void;
submitting: boolean; submitting: boolean;
readOnly?: boolean; readOnly?: boolean;
} /** Open-task card: show top-right control to cancel run and remove from list. */
showDismiss?: boolean;
/** Check if file matches accept string (e.g. ".pdf,image/*"). */ onDismiss?: () => void;
function relationshipTaskIdFromFormValue(v: unknown): string { dismissing?: boolean;
if (v && typeof v === 'object' && !Array.isArray(v) && 'add' in v) {
const a = (v as { add?: unknown[] }).add;
if (Array.isArray(a) && a[0] != null && String(a[0]).trim()) return String(a[0]);
}
return '';
}
function InputFormClickupTaskField({
connectionId,
listId,
value,
onChange,
request,
}: {
connectionId: string;
listId: string;
value: unknown;
onChange: (v: unknown) => void;
request: ApiRequestFunction;
}) {
const { t } = useLanguage();
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const cid = connectionId.trim();
const lid = listId.trim();
if (!cid || !lid) {
setTasks([]);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
loadClickupListTasksForDropdown(request, cid, lid)
.then((rows) => {
if (!cancelled) setTasks(rows);
})
.catch(() => {
if (!cancelled) {
setTasks([]);
setErr(t('Aufgaben konnten nicht geladen werden.'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [request, connectionId, listId]);
const sel = relationshipTaskIdFromFormValue(value);
if (!connectionId.trim() || !listId.trim()) {
return (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
{t('Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt — bitte Workflow prüfen.')}
</p>
);
}
return (
<>
{err ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>{t('lade Aufgaben')}</p>
) : (
<select
value={sel}
onChange={(e) => {
const tid = e.target.value;
if (!tid) onChange({ add: [], rem: [] });
else onChange({ add: [tid], rem: [] });
}}
>
<option value="">{t('Aufgabe wählen')}</option>
{tasks.map((taskRow) => (
<option key={taskRow.id} value={taskRow.id}>
{taskRow.name}
</option>
))}
</select>
)}
</>
);
} }
const TaskCard: React.FC<TaskCardProps> = ({ const TaskCard: React.FC<TaskCardProps> = ({
@ -507,9 +554,11 @@ const TaskCard: React.FC<TaskCardProps> = ({
onSubmit, onSubmit,
submitting, submitting,
readOnly = false, readOnly = false,
showDismiss = false,
onDismiss,
dismissing = false,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest();
const { handleFileUpload } = useFileOperations(); const { handleFileUpload } = useFileOperations();
const [formData, setFormData] = useState<Record<string, unknown>>({}); const [formData, setFormData] = useState<Record<string, unknown>>({});
const [formPopupOpen, setFormPopupOpen] = useState(false); const [formPopupOpen, setFormPopupOpen] = useState(false);
@ -521,6 +570,12 @@ const TaskCard: React.FC<TaskCardProps> = ({
const nodeType = task.nodeType; const nodeType = task.nodeType;
const stepLabel = getNodeStepLabel(config); const stepLabel = getNodeStepLabel(config);
const inputFormFields: WorkflowRuntimeFormFieldRow[] =
nodeType === 'input.form'
? ((config.fields as WorkflowRuntimeFormFieldRow[]) ?? [])
: [];
const inputFormRequiredOk = useWorkflowRuntimeFormRequiredOk(inputFormFields, formData);
useEffect(() => { useEffect(() => {
setUploadedFiles([]); setUploadedFiles([]);
setUploadError(null); setUploadError(null);
@ -530,82 +585,13 @@ const TaskCard: React.FC<TaskCardProps> = ({
if (readOnly) return null; if (readOnly) return null;
switch (nodeType) { switch (nodeType) {
case 'input.form': { case 'input.form': {
const fields =
(config.fields as Array<{
name: string;
type: string;
label: string;
required?: boolean;
clickupConnectionId?: string;
clickupListId?: string;
clickupStatusOptions?: Array<{ value: string; label: string }>;
}>) ?? [];
const requiredFields = fields.filter((f) => f.required);
const allRequiredFilled = requiredFields.every((f) => {
const v = formData[f.name];
if (f.type === 'boolean') return true;
if (f.type === 'clickup_tasks') {
return relationshipTaskIdFromFormValue(v) !== '';
}
if (f.type === 'clickup_status') {
return v !== undefined && v !== null && String(v).trim() !== '';
}
return v !== undefined && v !== null && String(v).trim() !== '';
});
const formContent = ( const formContent = (
<div className={styles.formFields}> <WorkflowRuntimeFormFields
{fields.map((f) => ( fields={inputFormFields}
<div key={f.name}> formData={formData}
<label> setFormData={setFormData}
{f.label || f.name} formFieldsClassName={styles.formFields}
{f.required && ' *'} />
</label>
{f.type === 'boolean' ? (
<input
type="checkbox"
checked={(formData[f.name] as boolean) ?? false}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.checked }))
}
/>
) : f.type === 'clickup_tasks' && request ? (
<InputFormClickupTaskField
connectionId={f.clickupConnectionId ?? ''}
listId={f.clickupListId ?? ''}
value={formData[f.name]}
onChange={(v) => setFormData((p) => ({ ...p, [f.name]: v }))}
request={request}
/>
) : f.type === 'clickup_status' &&
Array.isArray(f.clickupStatusOptions) &&
f.clickupStatusOptions.length > 0 ? (
<select
value={(formData[f.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
}
>
<option value="">{t('Status wählen')}</option>
{f.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={
f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'
}
value={(formData[f.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
}
/>
)}
</div>
))}
</div>
); );
return ( return (
<> <>
@ -630,7 +616,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
onSubmit({ payload: formData }); onSubmit({ payload: formData });
setFormPopupOpen(false); setFormPopupOpen(false);
}} }}
disabled={submitting || !allRequiredFilled} disabled={submitting || !inputFormRequiredOk}
className={styles.popupSubmitButton} className={styles.popupSubmitButton}
> >
{submitting ? t('wird gesendet') : t('absenden')} {submitting ? t('wird gesendet') : t('absenden')}
@ -897,8 +883,27 @@ const TaskCard: React.FC<TaskCardProps> = ({
} }
}; };
const cardClass = showDismiss
? `${styles.taskCard} ${styles.taskCardDismissable}`
: styles.taskCard;
return ( return (
<div className={styles.taskCard}> <div className={cardClass}>
{showDismiss && onDismiss ? (
<button
type="button"
className={styles.dismissOpenTaskBtn}
title={t('Task entfernen und Ausführung abbrechen')}
aria-label={t('Task entfernen und Ausführung abbrechen')}
disabled={submitting || dismissing}
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
{dismissing ? <FaSpinner className={styles.spinner} /> : <FaTimes />}
</button>
) : null}
<div className={styles.taskMeta}> <div className={styles.taskMeta}>
<div className={styles.taskMetaRow}> <div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>{t('Workflow')}</span> <span className={styles.metaLabel}>{t('Workflow')}</span>

View file

@ -1,57 +0,0 @@
/**
* WorkspaceKeepAlive
*
* Renders the WorkspacePage permanently at the MainLayout level so it
* survives route changes. Visibility is toggled via CSS `display`
* instead of mount / unmount, preserving messages, SSE connections,
* files, and all other workspace state.
*
* Persistence is scoped per `(mandateId, instanceId)` switching to a
* different mandate or instance via the navigator unmounts the previous
* page and mounts a fresh one (otherwise stale state from tenant A
* leaks into tenant B).
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { WorkspacePage } from './WorkspacePage';
const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/;
interface WorkspaceKeepAliveProps {
isVisible: boolean;
}
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
if (!instanceId) return null;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
</div>
);
};

View file

@ -1,45 +1,2 @@
// Copyright (c) 2025 Patrick Motsch // Vitest / jsdom setup (minimal).
// All rights reserved.
//
// Vitest global setup: jest-dom matchers + jsdom polyfills required by some
// of our components (ResizeObserver, matchMedia, scrollIntoView).
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
class _ResizeObserverPolyfill {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
if (!('ResizeObserver' in globalThis)) {
(globalThis as unknown as { ResizeObserver: typeof _ResizeObserverPolyfill }).ResizeObserver =
_ResizeObserverPolyfill;
}
if (!('matchMedia' in window)) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
if (!('scrollIntoView' in HTMLElement.prototype)) {
(HTMLElement.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView =
function (): void {};
}

View file

@ -1,23 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Smoke test that validates the Vitest + jsdom setup is wired correctly.
// If this fails the rest of the suite is meaningless.
import { describe, expect, it } from 'vitest';
describe('vitest smoke', () => {
it('runs in jsdom and has window/document', () => {
expect(typeof window).toBe('object');
expect(typeof document).toBe('object');
});
it('has jest-dom matchers via globals setup', () => {
const div = document.createElement('div');
div.textContent = 'hello';
document.body.appendChild(div);
expect(div).toBeInTheDocument();
expect(div).toHaveTextContent('hello');
document.body.removeChild(div);
});
});

View file

@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
export interface KeepAliveRenderContext {
mandateId: string;
instanceId: string;
scopeKey: string;
}
/** Mandate-scoped persistent routes: cache (mandateId, instanceId) from the URL while hidden. */
export interface KeepAliveScopedEntry {
id: string;
pathRegex: RegExp;
scopeRegex: RegExp;
/**
* If false, mount once instanceId is known (Workspace). If true, both ids required (Commcoach, Graphical Editor).
*/
requireMandateForMount?: boolean;
/** Commcoach shell omits overflow:hidden; other routes use hidden. */
shellOverflowHidden?: boolean;
render: (ctx: KeepAliveRenderContext) => ReactNode;
}
/** Routes kept alive without mandate/instance scope (e.g. admin). */
export interface KeepAliveUnscopedEntry {
id: string;
pathRegex: RegExp;
render: () => ReactNode;
}
export type KeepAliveEntry = KeepAliveScopedEntry | KeepAliveUnscopedEntry;
export function isKeepAliveScoped(entry: KeepAliveEntry): entry is KeepAliveScopedEntry {
return 'scopeRegex' in entry;
}

View file

@ -259,7 +259,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
icon: 'sitemap', icon: 'sitemap',
views: [ views: [
{ code: 'editor', label: 'Editor', path: 'editor' }, { code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'workflows', label: 'Workflows', path: 'workflows' },
{ code: 'templates', label: 'Vorlagen', path: 'templates' }, { code: 'templates', label: 'Vorlagen', path: 'templates' },
{ code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' }, { code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' }, { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },

531
src/utils/scheduleCron.ts Normal file
View file

@ -0,0 +1,531 @@
/**
* User-friendly schedule cron
* Standard: 5 Felder (minute hour dom month dow), DOW 0=So 6=Sa
* Intervall Sekunden: 6 Felder (sec min hour dom month dow)
*/
/** Primary planner modes (+ legacy aliases still read/written). */
export type ScheduleMode =
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| 'custom'
| 'daily'
| 'weekdays'
| 'weekly'
| 'calendar'
| 'interval';
export type CalendarPeriod = 'monthly' | 'yearly';
/** sek, min, h, T (Tage), a (Jahre) — legacy interval mode */
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
export interface ScheduleSpec {
mode: ScheduleMode;
hour: number;
minute: number;
/** 06 cron DOW; für weeks / weekly / weekdays */
weekdays: number[];
/** Tag des Monats 131 (Planner months: 128 empfohlen) */
monthDay: number;
/** 112, nur bei calendar + yearly (Legacy) */
monthIndex: number;
calendarPeriod: CalendarPeriod;
intervalValue: number;
intervalUnit: IntervalUnit;
/** mode === 'custom': Roh-Cron (5 oder 6 Felder) */
customCron: string;
/** mode === 'weeks': alle W Wochen (Phase 1: Cron entspricht W===1; W>1 nur in schedule persistiert) */
weeksInterval: number;
}
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
/** Anzeige MoSo (cron DOW) */
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
{ cronDow: 1, label: 'Mo' },
{ cronDow: 2, label: 'Di' },
{ cronDow: 3, label: 'Mi' },
{ cronDow: 4, label: 'Do' },
{ cronDow: 5, label: 'Fr' },
{ cronDow: 6, label: 'Sa' },
{ cronDow: 0, label: 'So' },
];
/** Minuten-Optionen für Dropdowns (wie Prototyp + feinere Schritte bis 59) */
export const MINUTE_SELECT_OPTIONS: readonly number[] = (() => {
const base = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
const set = new Set(base);
for (let i = 0; i < 60; i++) if (!set.has(i)) set.add(i);
return [...set].sort((a, b) => a - b);
})();
export function defaultScheduleSpec(): ScheduleSpec {
return {
mode: 'days',
hour: 9,
minute: 0,
weekdays: [1, 2, 3, 4, 5],
monthDay: 1,
monthIndex: 1,
calendarPeriod: 'monthly',
intervalValue: 1,
intervalUnit: 'minutes',
customCron: '0 9 * * 1-5',
weeksInterval: 1,
};
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Normalisiert Legacy-Modi für Planner-UI (ohne Cron neu zu bauen). */
export function normalizeSpecForPlanner(spec: ScheduleSpec): ScheduleSpec {
const s = { ...spec };
switch (s.mode) {
case 'daily':
return { ...s, mode: 'days', intervalValue: 1 };
case 'weekdays':
return {
...s,
mode: 'weeks',
weeksInterval: 1,
weekdays: [1, 2, 3, 4, 5],
};
case 'weekly':
return { ...s, mode: 'weeks', weeksInterval: s.weeksInterval || 1 };
case 'calendar':
if (s.calendarPeriod === 'yearly') {
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'calendar' }),
};
}
return {
...s,
mode: 'months',
intervalValue: 1,
monthDay: clamp(s.monthDay, 1, 28),
};
case 'interval': {
const u = s.intervalUnit;
if (u === 'minutes')
return { ...s, mode: 'minutes', intervalValue: Math.max(1, s.intervalValue) };
if (u === 'hours')
return {
...s,
mode: 'hours',
intervalValue: Math.max(1, s.intervalValue),
minute: 0,
};
if (u === 'days')
return {
...s,
mode: 'days',
intervalValue: Math.max(1, s.intervalValue),
hour: 0,
minute: 0,
};
if (u === 'seconds')
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
}
default:
return s;
}
}
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
export function buildCronFromSpec(spec: ScheduleSpec): string {
const m = clamp(Math.floor(spec.minute), 0, 59);
const h = clamp(Math.floor(spec.hour), 0, 23);
switch (spec.mode) {
case 'minutes': {
const mm = clamp(Math.floor(spec.intervalValue), 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(Math.floor(spec.intervalValue), 1, 23);
return `${m} */${hh} * * *`;
}
case 'days': {
const d = Math.max(1, Math.floor(spec.intervalValue));
if (d <= 1) return `${m} ${h} * * *`;
const step = clamp(d, 2, 31);
return `${m} ${h} */${step} * *`;
}
case 'weeks':
case 'weekly': {
const days = [...new Set(spec.weekdays)]
.filter((x) => x >= 0 && x <= 6)
.sort((a, b) => {
const order = (x: number) => (x === 0 ? 7 : x);
return order(a) - order(b);
});
// Phase 1: weeksInterval > 1 nicht als Cron abbildbar — gleicher weekly-Ausdruck
if (days.length === 0) return `${m} ${h} * * 1`;
return `${m} ${h} * * ${days.join(',')}`;
}
case 'months': {
const dom = clamp(Math.floor(spec.monthDay), 1, 28);
const monIv = Math.max(1, Math.floor(spec.intervalValue));
if (monIv <= 1) return `${m} ${h} ${dom} * *`;
const step = clamp(monIv, 2, 12);
return `${m} ${h} ${dom} */${step} *`;
}
case 'custom': {
const c = (spec.customCron || '').trim();
if (!c) return '0 9 * * *';
return c;
}
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
case 'calendar': {
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
if (spec.calendarPeriod === 'monthly') {
return `${m} ${h} ${dom} * *`;
}
const month = clamp(Math.floor(spec.monthIndex), 1, 12);
return `${m} ${h} ${dom} ${month} *`;
}
case 'interval': {
const v = Math.max(1, Math.floor(spec.intervalValue));
switch (spec.intervalUnit) {
case 'seconds': {
const sec = clamp(v, 1, 59);
return `*/${sec} * * * * *`;
}
case 'minutes': {
const mm = clamp(v, 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(v, 1, 23);
return `0 */${hh} * * *`;
}
case 'days': {
if (v <= 1) return `${m} ${h} * * *`;
const d = clamp(v, 2, 31);
return `${m} ${h} */${d} * *`;
}
case 'years':
default:
return `0 0 1 1 *`;
}
}
default:
return `${m} ${h} * * *`;
}
}
/** Validiert 5- oder 6-Feld-Cron (Whitespace-getrennt). */
export function isValidCronFieldCount(cron: string): boolean {
const n = cron.trim().split(/\s+/).length;
return n === 5 || n === 6;
}
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
if (!cron || typeof cron !== 'string') return null;
const trimmed = cron.trim();
const p = trimmed.split(/\s+/);
if (p.length === 6) {
const [secS, minS, hourS, domS, monthS, dowS] = p;
if (
secS.startsWith('*/') &&
minS === '*' &&
hourS === '*' &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(secS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'seconds',
minute: 0,
hour: 0,
};
}
}
const base = { ...defaultScheduleSpec(), mode: 'custom' as const, customCron: trimmed };
return base;
}
if (p.length < 5) return null;
let [minS, hourS, domS, monthS, dowS] = p;
if (minS.startsWith('*/') && hourS === '*' && domS === '*') {
const iv = parseInt(minS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'minutes',
intervalValue: iv,
minute: 0,
hour: 0,
};
}
}
const minNum = parseInt(minS, 10);
const hourNum = parseInt(hourS, 10);
if (
!minS.startsWith('*/') &&
!Number.isNaN(minNum) &&
hourS.startsWith('*/') &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'hours',
intervalValue: iv,
minute: minNum,
hour: 0,
};
}
}
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'hours',
intervalValue: iv,
minute: 0,
hour: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
domS.startsWith('*/') &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'days',
intervalValue: iv,
hour: hourNum,
minute: minNum,
};
}
}
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'days',
intervalValue: iv,
hour: 0,
minute: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
!domS.startsWith('*/') &&
monthS.startsWith('*/') &&
(dowS === '*' || dowS === '?')
) {
const dom = parseInt(domS, 10);
const iv = parseInt(monthS.slice(2), 10);
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && !Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'months',
intervalValue: iv,
monthDay: dom,
hour: hourNum,
minute: minNum,
};
}
}
const minute = minNum;
const hour = hourNum;
if (Number.isNaN(minute) || Number.isNaN(hour)) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
if (domS === '*' && dowS === '*') {
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
}
if (domS === '*' && dowS === '1-5') {
return {
...defaultScheduleSpec(),
mode: 'weekdays',
hour,
minute,
weekdays: [1, 2, 3, 4, 5],
};
}
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
const parts = dowS.split(',').map((x) => parseInt(x.trim(), 10));
const days = parts.filter((x) => !Number.isNaN(x) && x >= 0 && x <= 7);
if (days.length > 0) {
const norm = days.map((d) => (d === 7 ? 0 : d));
return {
...defaultScheduleSpec(),
mode: 'weekly',
hour,
minute,
weekdays: norm,
weeksInterval: 1,
};
}
}
const dom = parseInt(domS, 10);
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
if (
(domS.includes(',') || domS.includes('-') || domS.includes('/')) &&
!domS.startsWith('*/')
) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'monthly',
hour,
minute,
monthDay: dom,
};
}
if (
!Number.isNaN(dom) &&
dom >= 1 &&
dom <= 31 &&
!Number.isNaN(month) &&
month >= 1 &&
month <= 12 &&
(dowS === '*' || dowS === '?')
) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'yearly',
hour,
minute,
monthDay: dom,
monthIndex: month,
};
}
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
const VALID_MODES: ScheduleMode[] = [
'minutes',
'hours',
'days',
'weeks',
'months',
'custom',
'daily',
'weekdays',
'weekly',
'calendar',
'interval',
];
function normalizeIntervalUnit(u: unknown): IntervalUnit {
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
return 'minutes';
}
/** Liest Spec aus Node-Parametern (schedule-Objekt bevorzugt, sonst Cron parsen) */
export function scheduleSpecFromParams(params: Record<string, unknown>): ScheduleSpec {
const raw = params.schedule;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const o = raw as Record<string, unknown>;
let mode = o.mode as string;
if (mode === 'monthly') {
mode = 'calendar';
}
if (VALID_MODES.includes(mode as ScheduleMode)) {
const base = defaultScheduleSpec();
let calendarPeriod: CalendarPeriod = base.calendarPeriod;
if (mode === 'calendar') {
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
}
const spec: ScheduleSpec = {
mode: mode as ScheduleMode,
hour: Number.isFinite(Number(o.hour)) ? clamp(Number(o.hour), 0, 23) : base.hour,
minute: Number.isFinite(Number(o.minute)) ? clamp(Number(o.minute), 0, 59) : base.minute,
weekdays: Array.isArray(o.weekdays)
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
: base.weekdays,
monthDay: clamp(Number(o.monthDay) ?? base.monthDay, 1, 31),
monthIndex: clamp(Number(o.monthIndex) ?? base.monthIndex, 1, 12),
calendarPeriod,
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
intervalUnit: normalizeIntervalUnit(o.intervalUnit),
customCron: typeof o.customCron === 'string' ? o.customCron : base.customCron,
weeksInterval: Math.max(1, Number(o.weeksInterval) || base.weeksInterval),
};
return normalizeSpecForPlanner(spec);
}
}
const cron = typeof params.cron === 'string' ? params.cron : '';
const parsed = parseCronToSpec(cron);
return normalizeSpecForPlanner(parsed ?? defaultScheduleSpec());
}
/** Serialisiert Spec für parameters.schedule (persistiert). */
export function scheduleSpecToPersistentJson(spec: ScheduleSpec): Record<string, unknown> {
return {
mode: spec.mode,
hour: spec.hour,
minute: spec.minute,
weekdays: spec.weekdays,
monthDay: spec.monthDay,
monthIndex: spec.monthIndex,
calendarPeriod: spec.calendarPeriod,
intervalValue: spec.intervalValue,
intervalUnit: spec.intervalUnit,
customCron: spec.customCron,
weeksInterval: spec.weeksInterval,
};
}