Compare commits

...

56 commits

Author SHA1 Message Date
Ida
7d716bc205 neue context nodes hinzugefügt, muss noch debuggt werden 2026-05-06 13:49:28 +02:00
Ida
5ff75a63e3 node handover standartisiert, kein hardcoden mehr, inhalt extraktion node verbessert, output ports vereinheitlicht mit user im blick 2026-05-06 12:51:18 +02:00
Ida
0941b9e0ad finished file tree folder selection in file create node 2026-05-06 09:11:35 +02:00
Ida
47b3c1ab23 workign on folder location in file create node 2026-05-06 08:37:45 +02:00
Ida
25b56f585e fix: readded new folder button to folder tree component 2026-05-06 08:19:37 +02:00
Ida
930a34662d fix: falsche gruppierung entfernt, gruppierung richtig implementiert 2026-05-04 17:29:11 +02:00
ValueOn AG
d42fa02736 fix import 2026-05-04 09:33:14 +02:00
ValueOn AG
dca587a2df fixed ux for expand object scrolling 2026-05-04 09:33:14 +02:00
ValueOn AG
79557e51ed fixed component formgeneratortree and truastee workflows 2026-05-04 09:33:14 +02:00
Ida
3d580a5fca ValueOn Lead to Opportunity durchgespielt, bugfixes im datapicker und node hadover 2026-05-03 18:02:44 +02:00
Ida
1d2d247273 fix: alle Node definitionen korrigiert und im backend gesetzt - keine mapping layer sonder saubere quelldaten, fehlende dataRef parameter hinzugefügt, damit jede node kontext nutzen kann 2026-05-03 15:07:25 +02:00
Patrick Motsch
992c0472c6
Merge pull request #67 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-01 00:00:30 +02:00
ValueOn AG
02bf4020d7 build fixes 2026-05-01 00:00:06 +02:00
ValueOn AG
c7e94aea79 fixed nodes handovers 2026-04-30 23:54:51 +02:00
Ida
7c05cb0dd7 replaced file tree mit formgenerator gruppierung 2026-04-30 12:40:43 +02:00
Ida
e7a79a3484 UI Verbesserungen Gruppierung und Anwendung auf alle Seiten 2026-04-30 10:46:44 +02:00
ValueOn AG
ad96c6d861 fixes 2026-04-29 23:13:01 +02:00
ValueOn AG
70459d57e3 fixes before document generation refactory styles 2026-04-29 22:54:26 +02:00
ValueOn AG
8cecf3b320 plana+c implemented 2026-04-29 21:27:15 +02:00
Ida
aff9dcb7bd build errors 2026-04-29 18:35:42 +02:00
Ida
31586d62c1 build errors 2026-04-29 18:32:50 +02:00
Ida
c8e9304801 gruppierung fertig gestellt formgenerator 2026-04-29 18:25:42 +02:00
Ida
b61544d8b1 feat: rag extension frontend consent 2026-04-29 14:53:38 +02:00
Patrick Motsch
26958d1e16
Merge pull request #65 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-29 01:56:07 +02:00
ValueOn AG
28951a7d22 fixes infomaniac different than in doc 2026-04-29 00:57:24 +02:00
ValueOn AG
9e08953c44 kdrive fix 2026-04-29 00:35:11 +02:00
Patrick Motsch
a0c2323fe6
Merge pull request #63 from valueonag/feat/demo-system-readieness
fix build
2026-04-27 07:25:50 +02:00
ValueOn AG
34d6c2b83d fix build 2026-04-27 07:25:17 +02:00
Patrick Motsch
3f80d6d434
Merge pull request #62 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-27 00:00:49 +02:00
ValueOn AG
3016806db9 added infomaniak 2026-04-26 23:59:14 +02:00
Patrick Motsch
974c48e24d
Merge pull request #61 from valueonag/int
Int
2026-04-26 23:16:01 +02:00
Patrick Motsch
fe857d5ade
Merge pull request #59 from valueonag/feat/demo-system-readieness
build fix
2026-04-26 23:06:47 +02:00
ValueOn AG
a9e8e8cddd build fix 2026-04-26 23:05:15 +02:00
Patrick Motsch
2994f3a090
Merge pull request #58 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-26 22:54:40 +02:00
ValueOn AG
f0e73b62d2 Graph and data class falignment strict 2026-04-26 22:53:39 +02:00
ValueOn AG
8679cdffcb datamodel sctirc fk logic in one place 2026-04-26 18:11:52 +02:00
ValueOn AG
d8ff3a84d9 fixed user references 2026-04-26 08:57:47 +02:00
ValueOn AG
c47dc67a84 cleanup internal marked exports 2026-04-26 08:31:31 +02:00
ValueOn AG
e09ed758ff teamsbot 2026-04-25 01:13:13 +02:00
ValueOn AG
fc2cce8732 fixes 2026-04-23 23:09:54 +02:00
Patrick Motsch
0270f59d44
Merge pull request #56 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-22 00:00:08 +02:00
ValueOn AG
208f7b63df ui build fix 2026-04-21 23:56:19 +02:00
ValueOn AG
1c2a196192 fixes udb, outlook, workflow 2026-04-21 23:49:50 +02:00
ValueOn AG
c702740714 redmine integrated and fixed 2026-04-21 21:30:15 +02:00
ValueOn AG
0bdaf86153 redmine integration 2026-04-21 18:14:26 +02:00
Patrick Motsch
ebaaef7b4e
Merge pull request #54 from valueonag/feat/demo-system-readieness
fix critical trustee db sync
2026-04-21 10:46:22 +02:00
ValueOn AG
d771d4bc09 fix critical trustee db sync 2026-04-21 10:45:11 +02:00
Patrick Motsch
9093827e7c
Merge pull request #53 from valueonag/feat/demo-system-readieness
udb fix
2026-04-21 08:58:15 +02:00
ValueOn AG
1c4233c7ea udb fix 2026-04-21 08:57:49 +02:00
Patrick Motsch
45ea3ed48b
Merge pull request #51 from valueonag/feat/demo-system-readieness
fixes
2026-04-21 07:46:58 +02:00
ValueOn AG
8e5a01df6d fixes 2026-04-21 07:46:18 +02:00
Patrick Motsch
3f4a98381d
Merge pull request #49 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-21 00:55:25 +02:00
ValueOn AG
b4574b6a2e fixes 2026-04-21 00:54:57 +02:00
ValueOn AG
46d6ad1dfa Merge branch 'feat/demo-system-readieness' of https://github.com/valueonag/frontend_nyla into feat/demo-system-readieness 2026-04-21 00:50:46 +02:00
ValueOn AG
7d84160cdb data source fixes 2026-04-21 00:50:42 +02:00
Patrick Motsch
629d26c404
Merge pull request #48 from valueonag/int
Int
2026-04-20 19:12:44 +02:00
181 changed files with 27728 additions and 6162 deletions

View file

@ -23,6 +23,12 @@ export default tseslint.config(
'warn',
{ allowConstantExport: true },
],
'no-restricted-imports': [
'warn',
{
patterns: [],
},
],
},
},
)

1626
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,11 @@
"build:prod": "tsc -b && vite build --mode prod",
"build:int": "tsc -b && vite build --mode int",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@azure/msal-browser": "^4.12.0",
@ -47,18 +51,24 @@
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.7.2",
"@types/proj4": "^2.5.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^2.1.9",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"jsdom": "^25.0.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^5.4.10",
"vite-plugin-html": "^3.2.2"
"vite-plugin-html": "^3.2.2",
"vitest": "^2.1.9"
}
}

View file

@ -147,8 +147,7 @@ function App() {
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
<Route path="documents" element={<FeatureViewPage view="documents" />} />
<Route path="positions" element={<FeatureViewPage view="positions" />} />
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
<Route path="roles" element={<FeatureViewPage view="roles" />} />
<Route path="access" element={<FeatureViewPage view="access" />} />
<Route path="runs" element={<FeatureViewPage view="runs" />} />
@ -158,8 +157,7 @@ function App() {
<Route path="chat" element={<FeatureViewPage view="chat" />} />
<Route path="threads" element={<FeatureViewPage view="threads" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
@ -188,6 +186,10 @@ function App() {
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
{/* Redmine Feature Views */}
<Route path="stats" element={<FeatureViewPage view="stats" />} />
<Route path="browser" element={<FeatureViewPage view="browser" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} />
</Route>

View file

@ -46,7 +46,14 @@ import { getApiBaseUrl } from '../config/config';
const api = axios.create({
baseURL: getApiBaseUrl(),
withCredentials: true
withCredentials: true,
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
// endpoint never reach the route. Setting ``indexes: null`` switches
// the URLSearchParams visitor to repeat format. Applies globally so
// every endpoint with array query params gets it for free.
paramsSerializer: { indexes: null },
});
// Add a request interceptor to add the auth token, context headers, and log backend IP
@ -92,6 +99,20 @@ api.interceptors.request.use(
config.headers['Accept-Language'] = appLanguage;
}
// Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can
// resolve "now" for AI agents and user-visible time strings without
// hardcoding a server-side default. Mirrors the Accept-Language pattern.
if (config.headers) {
try {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (browserTimezone) {
config.headers['X-User-Timezone'] = browserTimezone;
}
} catch {
// Older browsers without Intl.DateTimeFormat: backend falls back to UTC
}
}
// Add multi-tenant context headers from URL (if not already set)
// This ensures Feature-Instance roles are loaded for permission checks
const context = getContextFromUrl();

View file

@ -1,4 +1,7 @@
import { ApiRequestOptions } from '../hooks/useApi';
import type { AttributeType } from '../utils/attributeTypeMapper';
export type { AttributeType };
// ============================================================================
// TYPES & INTERFACES
@ -7,7 +10,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface AttributeDefinition {
name: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
type: AttributeType;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;

View file

@ -29,13 +29,36 @@ export interface BillingTransaction {
aicoreProvider?: string;
aicoreModel?: string;
createdByUserId?: string;
createdAt?: string;
sysCreatedAt?: string;
mandateId?: string;
mandateName?: string;
userId?: string;
userName?: string;
}
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
export interface BillingTransactionsPaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface BillingTransactionsPaginatedResponse {
items: BillingTransaction[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
export interface BillingSettings {
id: string;
mandateId: string;
@ -56,8 +79,12 @@ export interface BillingSettingsUpdate {
rechargeMaxPerMonth?: number;
}
export type BillingBucketSize = 'day' | 'month' | 'year';
export interface UsageReport {
period: string;
dateFrom: string;
dateTo: string;
bucketSize: BillingBucketSize;
totalCost: number;
transactionCount: number;
costByProvider: Record<string, number>;
@ -65,6 +92,12 @@ export interface UsageReport {
costByFeature: Record<string, number>;
}
export interface StatisticsRangeRequest {
dateFrom: string;
dateTo: string;
bucketSize: BillingBucketSize;
}
export interface AccountSummary {
id: string;
mandateId: string;
@ -125,7 +158,31 @@ export async function fetchBalanceForMandate(
}
/**
* Fetch transaction history
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
* Endpoint: GET /api/billing/transactions?pagination=...
*/
export async function fetchTransactionsPaginated(
request: ApiRequestFunction,
params?: BillingTransactionsPaginationParams
): Promise<BillingTransactionsPaginatedResponse> {
const paginationObj: Record<string, unknown> = {};
if (params?.page !== undefined) paginationObj.page = params.page;
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params?.sort?.length) paginationObj.sort = params.sort;
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
if (params?.search) paginationObj.search = params.search;
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
return await request({
url: '/api/billing/transactions',
method: 'get',
params: { pagination: JSON.stringify(paginationObj) },
});
}
/**
* Fetch transaction history (legacy array window)
* Endpoint: GET /api/billing/transactions
*/
export async function fetchTransactions(
@ -141,24 +198,21 @@ export async function fetchTransactions(
}
/**
* Fetch usage statistics
* Endpoint: GET /api/billing/statistics/{period}
* Fetch usage statistics for an explicit date range.
* Endpoint: GET /api/billing/statistics
*/
export async function fetchStatistics(
request: ApiRequestFunction,
period: 'day' | 'month' | 'year',
year: number,
month?: number
range: StatisticsRangeRequest
): Promise<UsageReport> {
const params: Record<string, any> = { year };
if (month !== undefined) {
params.month = month;
}
return await request({
url: `/api/billing/statistics/${period}`,
url: '/api/billing/statistics',
method: 'get',
params
params: {
dateFrom: range.dateFrom,
dateTo: range.dateTo,
bucketSize: range.bucketSize,
},
});
}
@ -225,6 +279,19 @@ export async function addCreditAdmin(
});
}
/**
* Fetch the server-side allow-list of CHF top-up amounts
* Endpoint: GET /api/billing/checkout/amounts
*/
export async function fetchCheckoutAmounts(
request: ApiRequestFunction
): Promise<number[]> {
return await request({
url: '/api/billing/checkout/amounts',
method: 'get'
});
}
/**
* Create Stripe Checkout Session for credit top-up
* Endpoint: POST /api/billing/checkout/create/{mandateId}

View file

@ -4,10 +4,26 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES
// ============================================================================
export interface KnowledgePreferences {
schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number;
}
export interface Connection {
id: string;
userId: string;
authority: 'local' | 'google' | 'msft' | 'clickup';
authority: 'local' | 'google' | 'msft' | 'clickup' | 'infomaniak';
externalId: string;
externalUsername: string;
externalEmail?: string;
@ -15,6 +31,8 @@ export interface Connection {
connectedAt: number; // Backend uses float for UTC timestamp in seconds
lastChecked: number; // Backend uses float for UTC timestamp in seconds
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
[key: string]: any; // Allow additional properties
}
@ -37,6 +55,22 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
/** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */
viewKey?: string;
/** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface GroupBand {
path: string[];
label: string;
startRowIndex: number;
rowCount: number;
}
export interface GroupLayout {
levels: string[];
bands: GroupBand[];
}
export interface PaginatedResponse<T> {
@ -47,17 +81,21 @@ export interface PaginatedResponse<T> {
totalItems: number;
totalPages: number;
};
groupLayout?: GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
export interface CreateConnectionData {
id?: string;
userId?: string;
authority?: 'msft' | 'google' | 'clickup';
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority
authority?: 'msft' | 'google' | 'clickup' | 'infomaniak';
type?: 'msft' | 'google' | 'clickup' | 'infomaniak'; // Backend maps type → authority
externalId?: string;
externalUsername?: string;
externalEmail?: string;
status?: 'active' | 'expired' | 'revoked' | 'pending';
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
connectedAt?: number;
lastChecked?: number;
expiresAt?: number;
@ -103,6 +141,8 @@ export async function fetchConnections(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
@ -136,14 +176,20 @@ export async function createConnection(
/**
* Connect to a service (initiate OAuth)
* Endpoint: POST /api/connections/{connectionId}/connect
*
* @param reauth If true, forces the OAuth provider to re-show the consent screen.
* Required when newly added scopes (e.g. Calendar/Contacts after a
* feature rollout) need to be granted on top of the existing token.
*/
export async function connectService(
request: ApiRequestFunction,
connectionId: string
connectionId: string,
reauth: boolean = false
): Promise<ConnectResponse> {
return await request({
url: `/api/connections/${connectionId}/connect`,
method: 'post'
method: 'post',
data: reauth ? { reauth: true } : undefined,
});
}
@ -221,3 +267,28 @@ export async function refreshGoogleToken(
});
}
/**
* Submit an Infomaniak Personal Access Token (kdrive + mail) for an existing
* UserConnection. The backend validates the token via /1/profile and stores it
* as the connection's data-access bearer token.
* Endpoint: POST /api/infomaniak/connections/{connectionId}/token
*/
export async function submitInfomaniakToken(
request: ApiRequestFunction,
connectionId: string,
token: string
): Promise<{
id: string;
status: string;
type: string;
externalUsername: string;
externalEmail?: string | null;
lastChecked: number;
}> {
return await request({
url: `/api/infomaniak/connections/${connectionId}/token`,
method: 'post',
data: { token }
});
}

View file

@ -34,6 +34,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface PaginatedResponse<T> {
@ -44,6 +46,8 @@ export interface PaginatedResponse<T> {
totalItems: number;
totalPages: number;
};
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
// Type for the request function passed to API functions
@ -103,6 +107,8 @@ export async function fetchFiles(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
@ -186,110 +192,72 @@ export async function deleteFiles(
return uniqueIds.map(fileId => ({ success: true, fileId }));
}
export async function deleteFolders(
request: ApiRequestFunction,
folderIds: string[],
recursiveFolders: boolean = true
): Promise<{ deletedFiles: number; deletedFolders: number }> {
const uniqueIds = [...new Set(folderIds.filter(Boolean))];
if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 };
return await request({
url: '/api/files/batch-delete',
method: 'post',
data: { folderIds: uniqueIds, recursiveFolders }
});
}
// ============================================================================
// FOLDER API FUNCTIONS
// GROUP BULK API FUNCTIONS
// ============================================================================
export interface FolderInfo {
id: string;
name: string;
parentId: string | null;
fileCount?: number;
mandateId?: string;
featureInstanceId?: string;
createdAt?: number;
scope?: string;
neutralize?: boolean;
}
export async function fetchFolders(
/** Patch scope for all files in a group (recursive) */
export async function patchGroupScope(
request: ApiRequestFunction,
parentId?: string | null
): Promise<FolderInfo[]> {
const params: any = {};
if (parentId !== undefined && parentId !== null) {
params.parentId = parentId;
}
const data = await request({
url: '/api/files/folders',
method: 'get',
params,
});
return Array.isArray(data) ? data : [];
}
export async function createFolder(
request: ApiRequestFunction,
name: string,
parentId?: string | null
): Promise<FolderInfo> {
return await request({
url: '/api/files/folders',
method: 'post',
data: { name, parentId: parentId || null },
});
}
export async function renameFolder(
request: ApiRequestFunction,
folderId: string,
name: string
groupId: string,
scope: string
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'put',
data: { name },
url: `/api/files/groups/${groupId}/scope`,
method: 'patch',
data: { scope },
});
}
export async function deleteFolderApi(
/** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */
export async function patchGroupNeutralize(
request: ApiRequestFunction,
folderId: string,
recursive: boolean = false
groupId: string,
neutralize: boolean
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}`,
url: `/api/files/groups/${groupId}/neutralize`,
method: 'patch',
data: { neutralize },
});
}
/** Download all files in a group as ZIP */
export async function downloadGroupZip(groupId: string): Promise<void> {
const { default: api } = await import('../api');
const response = await api.get(`/api/files/groups/${groupId}/download`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `group-${groupId}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
/** Delete a group and optionally all its files */
export async function deleteGroup(
request: ApiRequestFunction,
groupId: string,
deleteItems: boolean = false
): Promise<any> {
return await request({
url: `/api/files/groups/${groupId}`,
method: 'delete',
params: { recursive },
params: { deleteItems },
});
}
export async function moveFolder(
request: ApiRequestFunction,
folderId: string,
targetParentId: string | null
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}/move`,
method: 'post',
data: { targetParentId },
});
}
export async function moveFile(
request: ApiRequestFunction,
fileId: string,
targetFolderId: string | null
): Promise<any> {
return await request({
url: `/api/files/${fileId}/move`,
method: 'post',
data: { targetFolderId },
});
/** @deprecated Group tree removed — use view-based grouping (viewKey). Returns empty array. */
export function collectGroupItemIds(
_groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
_groupId: string
): string[] {
const collect = (): string[] | null => null;
return collect() ?? [];
}
// Note: The following operations require special handling (FormData, blob responses)
@ -299,3 +267,121 @@ export async function moveFile(
// - previewFile: Requires flexible responseType (json or blob)
// These are kept in the hooks for now due to their special requirements
// ============================================================================
// FOLDER TYPES & API FUNCTIONS
// ============================================================================
export interface FolderInfo {
id: string;
name: string;
parentId: string | null;
mandateId: string;
featureInstanceId: string;
scope: string;
neutralize: boolean;
contextOrphan?: boolean;
sysCreatedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
}
export async function getFolderTree(
request: ApiRequestFunction,
owner: 'me' | 'shared' = 'me',
): Promise<FolderInfo[]> {
const data = await request({
url: '/api/files/folders/tree',
method: 'get',
params: { owner },
});
return Array.isArray(data) ? data : [];
}
export async function createFolder(
request: ApiRequestFunction,
name: string,
parentId?: string | null,
): Promise<FolderInfo> {
return await request({
url: '/api/files/folders',
method: 'post',
data: { name, parentId: parentId ?? null },
});
}
export async function renameFolder(
request: ApiRequestFunction,
folderId: string,
name: string,
): Promise<FolderInfo> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'patch',
data: { name },
});
}
export async function moveFolder(
request: ApiRequestFunction,
folderId: string,
parentId: string | null,
): Promise<FolderInfo> {
return await request({
url: `/api/files/folders/${folderId}/move`,
method: 'post',
data: { parentId },
});
}
export async function deleteFolderCascade(
request: ApiRequestFunction,
folderId: string,
): Promise<{ deletedFolders: number; deletedFiles: number }> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'delete',
params: { cascade: true },
});
}
export async function patchFolderScope(
request: ApiRequestFunction,
folderId: string,
scope: string,
cascadeToFiles: boolean = false,
): Promise<{ folderId: string; scope: string; filesUpdated: number }> {
return await request({
url: `/api/files/folders/${folderId}/scope`,
method: 'patch',
data: { scope, cascadeToFiles },
});
}
export async function patchFolderNeutralize(
request: ApiRequestFunction,
folderId: string,
neutralize: boolean,
): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> {
return await request({
url: `/api/files/folders/${folderId}/neutralize`,
method: 'patch',
data: { neutralize },
});
}
export async function moveFiles(
request: ApiRequestFunction,
fileIds: string[],
targetFolderId: string | null,
): Promise<void> {
await Promise.all(
fileIds.map((fileId) =>
request({
url: `/api/files/${fileId}`,
method: 'put',
data: { folderId: targetFolderId },
}),
),
);
}

View file

@ -46,6 +46,7 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
}
export interface PaginatedResponse<T> {
@ -84,6 +85,7 @@ export async function fetchMandates(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -49,6 +49,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface PaginatedResponse<T> {
@ -59,6 +61,8 @@ export interface PaginatedResponse<T> {
totalItems: number;
totalPages: number;
};
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
export interface CreatePromptData {
@ -110,6 +114,8 @@ export async function fetchPrompts(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

398
src/api/redmineApi.ts Normal file
View file

@ -0,0 +1,398 @@
/**
* Redmine API
*
* Frontend client for the Redmine feature backend.
* URL pattern: /api/redmine/{instanceId}/...
*/
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// Types -- mirror gateway/modules/features/redmine/datamodelRedmine.py
// ============================================================================
export interface RedmineConfigDto {
id?: string;
featureInstanceId: string;
mandateId?: string | null;
baseUrl: string;
projectId: string;
hasApiKey: boolean;
rootTrackerName: string;
defaultPeriodValue?: Record<string, any> | null;
schemaCacheTtlSeconds: number;
schemaCachedAt?: number | null;
isActive: boolean;
lastConnectedAt?: number | null;
lastSyncAt?: number | null;
lastFullSyncAt?: number | null;
lastSyncTicketCount?: number | null;
lastSyncErrorMessage?: string | null;
}
export interface RedmineConfigUpdateRequest {
baseUrl?: string;
projectId?: string;
apiKey?: string;
rootTrackerName?: string;
defaultPeriodValue?: Record<string, any> | null;
schemaCacheTtlSeconds?: number;
isActive?: boolean;
}
export interface RedmineFieldChoice {
id: number;
name: string;
isClosed?: boolean | null;
}
export interface RedmineCustomFieldSchema {
id: number;
name: string;
fieldFormat: string;
isRequired: boolean;
possibleValues: string[];
multiple: boolean;
defaultValue?: string | null;
}
export interface RedmineFieldSchema {
projectId: string;
projectName: string;
trackers: RedmineFieldChoice[];
statuses: RedmineFieldChoice[];
priorities: RedmineFieldChoice[];
users: RedmineFieldChoice[];
categories: RedmineFieldChoice[];
customFields: RedmineCustomFieldSchema[];
rootTrackerName: string;
rootTrackerId: number | null;
}
export interface RedmineRelation {
id: number;
issueId: number;
issueToId: number;
relationType: string;
delay?: number | null;
}
export interface RedmineCustomFieldValue {
id: number;
name: string;
value: any;
}
export interface RedmineTicket {
id: number;
subject: string;
description: string;
trackerId?: number | null;
trackerName?: string | null;
statusId?: number | null;
statusName?: string | null;
isClosed: boolean;
priorityId?: number | null;
priorityName?: string | null;
assignedToId?: number | null;
assignedToName?: string | null;
authorId?: number | null;
authorName?: string | null;
parentId?: number | null;
fixedVersionId?: number | null;
fixedVersionName?: string | null;
categoryId?: number | null;
categoryName?: string | null;
createdOn?: string | null;
updatedOn?: string | null;
customFields: RedmineCustomFieldValue[];
relations: RedmineRelation[];
}
export interface RedmineSyncResult {
instanceId: string;
full: boolean;
ticketsUpserted: number;
relationsUpserted: number;
durationMs: number;
lastSyncAt: number;
error?: string | null;
}
export interface RedmineSyncStatus {
instanceId: string;
lastSyncAt?: number | null;
lastFullSyncAt?: number | null;
lastSyncDurationMs?: number | null;
lastSyncTicketCount?: number | null;
lastSyncErrorAt?: number | null;
lastSyncErrorMessage?: string | null;
mirroredTicketCount: number;
mirroredRelationCount: number;
}
export interface RedmineConnectionTestResult {
ok: boolean;
reason?: string;
message?: string;
status?: number;
user?: { id: number; name: string };
project?: { id: number; name: string };
}
export interface RedmineStats {
instanceId: string;
dateFrom?: string | null;
dateTo?: string | null;
bucket: string;
trackerIds: number[];
categoryIds: number[];
statusFilter: string;
kpis: {
total: number;
open: number;
closed: number;
closedInPeriod: number;
createdInPeriod: number;
orphans: number;
};
statusByTracker: Array<{
trackerId?: number | null;
trackerName: string;
countsByStatus: Record<string, number>;
total: number;
}>;
throughput: Array<{
bucketKey: string;
label: string;
created: number;
closed: number;
cumTotal: number;
cumOpen: number;
}>;
topAssignees: Array<{
assignedToId?: number | null;
name: string;
open: number;
}>;
relationDistribution: Array<{ relationType: string; count: number }>;
backlogAging: Array<{
bucketKey: string;
label: string;
minDays: number;
maxDays?: number | null;
count: number;
}>;
}
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`;
// ============================================================================
// Config
// ============================================================================
export async function getRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineConfigDto> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' });
}
export async function updateRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
body: RedmineConfigUpdateRequest,
): Promise<RedmineConfigDto> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'put', data: body });
}
export async function deleteRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<{ deleted: boolean }> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'delete' });
}
export async function testRedmineConnectionApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineConnectionTestResult> {
return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' });
}
// ============================================================================
// Schema
// ============================================================================
export async function getRedmineSchemaApi(
request: ApiRequestFunction,
instanceId: string,
forceRefresh = false,
): Promise<RedmineFieldSchema> {
return await request({
url: `${_baseUrl(instanceId)}/schema`,
method: 'get',
params: forceRefresh ? { forceRefresh: true } : undefined,
});
}
// ============================================================================
// Sync
// ============================================================================
export async function runRedmineSyncApi(
request: ApiRequestFunction,
instanceId: string,
force = false,
): Promise<RedmineSyncResult> {
return await request({
url: `${_baseUrl(instanceId)}/sync`,
method: 'post',
params: force ? { force: true } : undefined,
});
}
export async function getRedmineSyncStatusApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineSyncStatus> {
return await request({ url: `${_baseUrl(instanceId)}/sync/status`, method: 'get' });
}
// ============================================================================
// Tickets
// ============================================================================
export interface ListTicketsParams {
trackerIds?: number[];
status?: 'open' | 'closed' | '*';
dateFrom?: string;
dateTo?: string;
assignedToId?: number;
}
export async function listRedmineTicketsApi(
request: ApiRequestFunction,
instanceId: string,
params: ListTicketsParams = {},
): Promise<RedmineTicket[]> {
const queryParams: Record<string, any> = {};
if (params.status) queryParams.status = params.status;
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
if (params.dateTo) queryParams.dateTo = params.dateTo;
if (params.assignedToId !== undefined) queryParams.assignedToId = params.assignedToId;
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
return await request({
url: `${_baseUrl(instanceId)}/tickets`,
method: 'get',
params: queryParams,
});
}
export async function getRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'get',
});
}
export interface RedmineTicketUpdateBody {
subject?: string;
description?: string;
trackerId?: number;
statusId?: number;
priorityId?: number;
assignedToId?: number;
parentIssueId?: number;
fixedVersionId?: number;
notes?: string;
customFields?: Record<number, any>;
}
export async function updateRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
body: RedmineTicketUpdateBody,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'put',
data: body,
});
}
export interface RedmineTicketCreateBody {
subject: string;
trackerId: number;
description?: string;
statusId?: number;
priorityId?: number;
assignedToId?: number;
parentIssueId?: number;
fixedVersionId?: number;
customFields?: Record<number, any>;
}
export async function createRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
body: RedmineTicketCreateBody,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets`,
method: 'post',
data: body,
});
}
export async function deleteRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
fallbackStatusId?: number,
): Promise<{ deleted: boolean; archived: boolean; statusId: number | null }> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'delete',
params: fallbackStatusId !== undefined ? { fallbackStatusId } : undefined,
});
}
// ============================================================================
// Stats
// ============================================================================
export interface RedmineStatsParams {
dateFrom?: string;
dateTo?: string;
bucket?: 'day' | 'week' | 'month';
trackerIds?: number[];
categoryIds?: number[];
statusFilter?: '*' | 'open' | 'closed';
}
export async function getRedmineStatsApi(
request: ApiRequestFunction,
instanceId: string,
params: RedmineStatsParams = {},
): Promise<RedmineStats> {
const queryParams: Record<string, any> = {};
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
if (params.dateTo) queryParams.dateTo = params.dateTo;
if (params.bucket) queryParams.bucket = params.bucket;
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds;
if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter;
return await request({
url: `${_baseUrl(instanceId)}/stats`,
method: 'get',
params: queryParams,
});
}

59
src/api/tableViewApi.ts Normal file
View file

@ -0,0 +1,59 @@
import api from '../api';
export interface TableListViewRow {
id: string;
userId?: string;
mandateId?: string | null;
contextKey: string;
viewKey: string;
displayName: string;
config: TableViewConfig;
updatedAt?: number;
}
export interface TableViewConfig {
schemaVersion?: number;
filters?: Record<string, unknown>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
groupByLevels?: Array<{ field: string; nullLabel?: string }>;
/** Section mode (`tableGroupLayoutMode="sections"`): stable keys (`sk`) of collapsed sections. */
collapsedSectionKeys?: string[];
/** Inline `groupLayout` bands: keys are `band.path.join('///')`. */
collapsedGroupKeys?: string[];
}
export async function listTableViews(contextKey: string): Promise<TableListViewRow[]> {
const { data } = await api.get<TableListViewRow[]>('/api/table-views', {
params: { contextKey },
});
return Array.isArray(data) ? data : [];
}
export async function getTableView(contextKey: string, viewKey: string): Promise<TableListViewRow> {
const { data } = await api.get<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewKey)}`, {
params: { contextKey },
});
return data;
}
export async function createTableView(payload: {
contextKey: string;
viewKey: string;
displayName: string;
config: TableViewConfig;
}): Promise<TableListViewRow> {
const { data } = await api.post<TableListViewRow>('/api/table-views', payload);
return data;
}
export async function updateTableView(
viewId: string,
updates: { displayName?: string; viewKey?: string; config?: TableViewConfig },
): Promise<TableListViewRow> {
const { data } = await api.put<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewId)}`, updates);
return data;
}
export async function deleteTableView(viewId: string): Promise<void> {
await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`);
}

View file

@ -169,11 +169,63 @@ export interface MfaChallengeEvent {
// SSE Event Types
export interface TeamsbotSSEEvent {
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed';
type:
| 'transcript'
| 'botResponse'
| 'analysis'
| 'suggestedResponse'
| 'statusChange'
| 'error'
| 'ping'
| 'sessionState'
| 'ttsDeliveryStatus'
| 'mfaChallenge'
| 'mfaResolved'
| 'chatSendFailed'
| 'directorPrompt'
| 'agentRun'
| 'botConnectionState';
data: any;
timestamp?: string;
}
// =========================================================================
// Director Prompts (private operator instructions during a live meeting)
// =========================================================================
export type DirectorPromptMode = 'oneShot' | 'persistent';
export type DirectorPromptStatus =
| 'queued'
| 'running'
| 'succeeded'
| 'failed'
| 'consumed';
export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000;
export const DIRECTOR_PROMPT_FILE_LIMIT = 10;
export interface DirectorPrompt {
id: string;
sessionId: string;
instanceId: string;
operatorUserId: string;
text: string;
mode: DirectorPromptMode;
fileIds: string[];
status: DirectorPromptStatus;
statusMessage?: string;
createdAt: string;
consumedAt?: string;
agentRunId?: string;
responseText?: string;
}
export interface DirectorPromptCreateRequest {
text: string;
mode: DirectorPromptMode;
fileIds?: string[];
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
@ -289,6 +341,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System
return response.data;
}
/**
* Create a new system bot account. The password is encrypted server-side
* before storage; the API never returns the password back. SysAdmin only.
*/
export async function createSystemBot(
instanceId: string,
payload: { email: string; password: string; name?: string },
): Promise<{ bot: SystemBot }> {
const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload);
return response.data;
}
/**
* Delete a system bot account. SysAdmin only.
*/
export async function deleteSystemBot(
instanceId: string,
botId: string,
): Promise<{ deleted: boolean }> {
const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`);
return response.data;
}
/**
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
*/
@ -452,3 +527,50 @@ export async function submitMfaCode(
});
return response.data;
}
// =========================================================================
// Director Prompts
// =========================================================================
/**
* Submit a private director prompt to the running bot. Triggers the full
* agent path (web, mail, RAG, etc.) and delivers the answer into the meeting.
*/
export async function submitDirectorPrompt(
instanceId: string,
sessionId: string,
body: DirectorPromptCreateRequest,
): Promise<{ prompt: DirectorPrompt }> {
const response = await api.post(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
body,
);
return response.data;
}
/**
* List director prompts for a session (operator's own prompts only).
*/
export async function listDirectorPrompts(
instanceId: string,
sessionId: string,
): Promise<{ prompts: DirectorPrompt[] }> {
const response = await api.get(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
);
return response.data;
}
/**
* Remove a (typically persistent) director prompt.
*/
export async function deleteDirectorPrompt(
instanceId: string,
sessionId: string,
promptId: string,
): Promise<{ deleted: boolean; promptId: string }> {
const response = await api.delete(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`,
);
return response.data;
}

View file

@ -853,16 +853,46 @@ export async function fetchChartOfAccounts(
});
}
/**
* Submits a background job that pushes positions to the accounting system and
* polls `/api/jobs/{jobId}` until the job reaches a terminal status. Returns
* the same `{ total, success, skipped, errors, results }` payload that the
* legacy synchronous endpoint used to return -- but does NOT block the user
* while the (potentially long) external accounting calls run in the worker.
*/
export async function syncPositionsToAccounting(
request: ApiRequestFunction,
instanceId: string,
positionIds: string[]
): Promise<{ total: number; success: number; errors: number; results: any[] }> {
return await request({
positionIds: string[],
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
const submission = await request({
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
method: 'post',
data: { positionIds }
});
const jobId: string | undefined = submission?.jobId;
if (!jobId) {
throw new Error('Background job could not be started (missing jobId).');
}
const pollMs = opts?.pollMs ?? 1500;
const TERMINAL = new Set(['SUCCESS', 'ERROR', 'CANCELLED']);
while (true) {
const job = await request({ url: `/api/jobs/${jobId}`, method: 'get' });
if (opts?.onProgress) {
opts.onProgress(Number(job?.progress ?? 0), job?.progressMessage ?? null);
}
if (job?.status && TERMINAL.has(job.status)) {
if (job.status === 'SUCCESS' && job.result) {
return job.result;
}
throw new Error(job?.errorMessage || 'Sync-Job fehlgeschlagen');
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
}
export async function fetchSyncStatus(
@ -875,6 +905,91 @@ export async function fetchSyncStatus(
});
}
// ============================================================================
// READ-ONLY DATA TABLE API (Daten-Tabellen page)
// ============================================================================
//
// Generic read-only endpoints for the consolidated data tables view.
// All entities are paginated, sortable, filterable via the Unified Filter API
// (mode=filterValues / mode=ids); no CRUD writes are exposed by these helpers.
export interface TrusteeDataAccount { id: string; [key: string]: any; }
export interface TrusteeDataJournalEntry { id: string; [key: string]: any; }
export interface TrusteeDataJournalLine { id: string; [key: string]: any; }
export interface TrusteeDataContact { id: string; [key: string]: any; }
export interface TrusteeDataAccountBalance { id: string; [key: string]: any; }
export interface TrusteeAccountingConfigRecord { id: string; [key: string]: any; }
export interface TrusteeAccountingSyncRecord { id: string; [key: string]: any; }
async function _fetchReadOnlyTable<T = any>(
request: ApiRequestFunction,
instanceId: string,
pathSegment: string,
params?: PaginationParams
): Promise<PaginatedResponse<T> | T[]> {
return await request({
url: `${_getTrusteeBaseUrl(instanceId)}/${pathSegment}`,
method: 'get',
params: _buildPaginationParams(params),
});
}
export async function fetchDataAccounts(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataAccount> | TrusteeDataAccount[]> {
return _fetchReadOnlyTable<TrusteeDataAccount>(request, instanceId, 'data/accounts', params);
}
export async function fetchDataJournalEntries(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataJournalEntry> | TrusteeDataJournalEntry[]> {
return _fetchReadOnlyTable<TrusteeDataJournalEntry>(request, instanceId, 'data/journal-entries', params);
}
export async function fetchDataJournalLines(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataJournalLine> | TrusteeDataJournalLine[]> {
return _fetchReadOnlyTable<TrusteeDataJournalLine>(request, instanceId, 'data/journal-lines', params);
}
export async function fetchDataContacts(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataContact> | TrusteeDataContact[]> {
return _fetchReadOnlyTable<TrusteeDataContact>(request, instanceId, 'data/contacts', params);
}
export async function fetchDataAccountBalances(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataAccountBalance> | TrusteeDataAccountBalance[]> {
return _fetchReadOnlyTable<TrusteeDataAccountBalance>(request, instanceId, 'data/account-balances', params);
}
export async function fetchAccountingConfigs(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccountingConfigRecord> | TrusteeAccountingConfigRecord[]> {
return _fetchReadOnlyTable<TrusteeAccountingConfigRecord>(request, instanceId, 'accounting/configs', params);
}
export async function fetchAccountingSyncs(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccountingSyncRecord> | TrusteeAccountingSyncRecord[]> {
return _fetchReadOnlyTable<TrusteeAccountingSyncRecord>(request, instanceId, 'accounting/syncs', params);
}
export async function exportAccountingData(
request: ApiRequestFunction,
instanceId: string

View file

@ -48,6 +48,7 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
}
export interface PaginatedResponse<T> {
@ -152,6 +153,7 @@ export async function fetchUsers(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -26,8 +26,16 @@ export interface NodeTypeParameter {
export interface PortField {
name: string;
type: string;
description: Record<string, string>;
/** Plain string or per-language map from the API catalog. */
description: string | Record<string, string>;
required: boolean;
enumValues?: string[] | null;
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
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 {
@ -35,14 +43,39 @@ export interface PortSchema {
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 {
accepts: string[];
}
/** Graph-defined output schema (e.g. form fields from node parameters). */
export interface GraphDefinedSchemaRef {
kind: 'fromGraph';
parameter: string;
}
export interface OutputPortDef {
schema: string;
schema: string | GraphDefinedSchemaRef;
dynamic?: boolean;
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 {
@ -66,7 +99,6 @@ export interface NodeType {
action?: string;
};
}
export interface NodeTypeCategory {
id: string;
label: Record<string, string> | string;
@ -77,11 +109,19 @@ export interface SystemVariable {
description: string;
}
/** Single form field type with its canonical port primitive. Delivered by GET /node-types. */
export interface FormFieldType {
id: string;
label: string;
portType: string;
}
export interface NodeTypesResponse {
nodeTypes: NodeType[];
categories: NodeTypeCategory[];
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
}
export interface Automation2GraphNode {
@ -89,7 +129,7 @@ export interface Automation2GraphNode {
type: string;
parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
}
export interface Automation2Connection {
@ -108,6 +148,10 @@ export interface ExecuteGraphResponse {
success: boolean;
nodeOutputs?: Record<string, unknown>;
error?: string;
/** Soft, non-blocking message displayed alongside a successful response.
* Used e.g. by the Save flow to surface "Gespeichert mit X Pflicht-Fehlern"
* without flipping `success` to `false`. */
warning?: string;
stopped?: boolean;
failedNode?: string;
paused?: boolean;
@ -132,6 +176,8 @@ export interface Automation2Workflow {
label: string;
graph: Automation2Graph;
active?: boolean;
/** Target feature instance for execution data scope (NULL for templates) */
targetFeatureInstanceId?: string | null;
/** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[];
/** Enriched: run count */
@ -144,8 +190,8 @@ export interface Automation2Workflow {
stuckAtNodeId?: string;
/** Enriched: human-readable label for stuck node */
stuckAtNodeLabel?: string;
/** Enriched: created timestamp (seconds) */
createdAt?: number;
/** From PowerOnModel base — record creation timestamp (seconds) */
sysCreatedAt?: number;
/** Enriched: last run started timestamp (seconds) */
lastStartedAt?: number;
}
@ -263,8 +309,57 @@ export async function fetchNodeTypes(
});
const nodeTypes = data?.nodeTypes ?? [];
const categories = data?.categories ?? [];
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
return { nodeTypes, categories };
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
const systemVariables = data?.systemVariables ?? undefined;
const formFieldTypes = data?.formFieldTypes ?? undefined;
console.log(
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` +
`${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes`
);
return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes };
}
export interface UpstreamPathEntry {
producerNodeId: string;
producerLabel?: string;
path: (string | number)[];
type: string;
label: string;
scopeOrigin: 'data' | 'loop' | 'system';
}
/**
* POST /api/workflows/{instanceId}/upstream-paths pickable upstream paths for DataPicker / AI.
*/
export async function postUpstreamPaths(
request: ApiRequestFunction,
instanceId: string,
graph: Automation2Graph,
nodeId: string
): Promise<{ paths: UpstreamPathEntry[] }> {
const data = await request({
url: `/api/workflows/${instanceId}/upstream-paths`,
method: 'post',
data: { graph, nodeId },
});
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
}
/** GET saved workflow graph variant of upstream-paths (requires workflowId). */
export async function getUpstreamPathsSaved(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
nodeId: string
): Promise<{ paths: UpstreamPathEntry[] }> {
const data = await request({
url: `/api/workflows/${instanceId}/upstream-paths/${encodeURIComponent(nodeId)}`,
method: 'get',
params: { workflowId },
});
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
}
/**
@ -353,7 +448,12 @@ export async function fetchWorkflow(
export async function createWorkflow(
request: ApiRequestFunction,
instanceId: string,
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
body: {
label: string;
graph: Automation2Graph;
invocations?: WorkflowEntryPoint[];
targetFeatureInstanceId?: string | null;
}
): Promise<Automation2Workflow> {
return await request({
url: `/api/workflows/${instanceId}/workflows`,
@ -372,6 +472,7 @@ export async function updateWorkflow(
invocations?: WorkflowEntryPoint[];
active?: boolean;
notifyOnFailure?: boolean;
targetFeatureInstanceId?: string | null;
}
): Promise<Automation2Workflow> {
return await request({
@ -927,3 +1028,95 @@ export async function loadClickupListTasksForDropdown(
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}
// ============================================================================
// AUTOMATION WORKSPACE API (user-facing run workspace)
// ============================================================================
export interface WorkspaceRun {
id: string;
workflowId: string;
workflowLabel?: string;
status: string;
startedAt?: number;
completedAt?: number;
ownerId?: string;
mandateId?: string;
mandateLabel?: string;
targetFeatureInstanceId?: string;
targetInstanceLabel?: string;
costTokens?: number;
costCredits?: number;
error?: string;
}
export interface WorkspaceRunDetail {
run: WorkspaceRun & { nodeOutputs?: Record<string, unknown> };
workflow: {
id: string;
label: string;
targetFeatureInstanceId?: string;
featureInstanceId?: string;
tags?: string[];
} | null;
steps: Array<{
id: string;
runId: string;
nodeId: string;
nodeType: string;
status: string;
inputSnapshot?: Record<string, unknown>;
output?: Record<string, unknown>;
inputFiles?: Array<{ id: string; fileName?: string }>;
outputFiles?: Array<{ id: string; fileName?: string }>;
error?: string;
startedAt?: number;
completedAt?: number;
durationMs?: number;
tokensUsed?: number;
retryCount?: number;
}>;
files: Array<{
id: string;
fileName?: string;
contentType?: string;
sizeBytes?: number;
}>;
unassignedFiles?: Array<{
id: string;
fileName?: string;
}>;
}
export async function fetchWorkspaceRuns(
request: ApiRequestFunction,
params: {
scope?: 'mine' | 'mandate';
status?: string;
targetInstanceId?: string;
workflowId?: string;
limit?: number;
offset?: number;
} = {},
): Promise<{ runs: WorkspaceRun[]; total: number }> {
const query = new URLSearchParams();
if (params.scope) query.set('scope', params.scope);
if (params.status) query.set('status', params.status);
if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId);
if (params.workflowId) query.set('workflowId', params.workflowId);
if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset));
const qs = query.toString();
const url = `/api/automations/runs${qs ? `?${qs}` : ''}`;
const resp = await request({ url, method: 'get' });
return resp as { runs: WorkspaceRun[]; total: number };
}
export async function fetchWorkspaceRunDetail(
request: ApiRequestFunction,
runId: string,
): Promise<WorkspaceRunDetail> {
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'get' });
return resp as WorkspaceRunDetail;
}

View file

@ -0,0 +1,467 @@
/* AddConnectionWizard styles */
.stepper {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: 1rem 1.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
background: var(--bg-secondary, #f0f0f0);
color: var(--text-secondary, #666);
border: 2px solid var(--border-color, #ddd);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.stepDotActive {
background: var(--primary-color, #f25843);
border-color: var(--primary-color, #f25843);
color: white;
}
.stepDotDone {
background: var(--success-color, #22c55e);
border-color: var(--success-color, #22c55e);
color: white;
}
.stepDotHidden {
opacity: 0.3;
}
.body {
padding: 1.5rem;
overflow-y: auto;
}
.stepContent {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 220px;
}
.stepTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.stepBody {
font-size: 0.9375rem;
color: var(--text-primary);
line-height: 1.6;
margin: 0;
}
.stepHint {
font-size: 0.8125rem;
color: var(--text-secondary, #666);
margin: 0;
}
/* Connector grid (Step 0) */
.connectorGrid {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.connectorCard {
flex: 1 1 140px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.25rem 1rem;
background: var(--surface-color);
border: 2px solid var(--border-color, #ddd);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
}
.connectorCard:hover {
border-color: var(--primary-color, #f25843);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.connectorIcon {
font-size: 1.75rem;
}
.connectorLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
/* Consent step (Step 1) */
.consentIcon {
display: flex;
justify-content: center;
color: var(--primary-color, #f25843);
}
.consentButtons {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.consentButtonYes {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.consentButtonYes:hover {
background: var(--primary-dark, #d94d3a);
}
.consentButtonNo {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--surface-color);
color: var(--text-primary);
border: 2px solid var(--border-color, #ddd);
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.consentButtonNo:hover {
border-color: var(--text-secondary, #888);
background: var(--bg-secondary, #f5f5f5);
}
/* Preferences step (Step 2) */
.prefGroup {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color, #eee);
}
.prefGroup:last-of-type {
border-bottom: none;
}
.prefLabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--text-primary);
cursor: pointer;
font-weight: 500;
}
.prefLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
color: var(--text-primary);
font-weight: 500;
}
.prefIcon {
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.prefCheck {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--primary-color, #f25843);
}
.prefSelect {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-color);
color: var(--text-primary);
min-width: 200px;
}
.prefNumber {
width: 80px;
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-color);
color: var(--text-primary);
text-align: right;
}
.prefHint {
font-size: 0.8125rem;
color: var(--text-secondary, #666);
margin: 0;
}
/* Summary step (Step 3) */
.summary {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
overflow: hidden;
}
.summaryRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 1rem;
gap: 1rem;
border-bottom: 1px solid var(--border-color, #eee);
}
.summaryRow:last-child {
border-bottom: none;
}
.summaryKey {
font-size: 0.875rem;
color: var(--text-secondary, #666);
font-weight: 500;
}
.summaryVal {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
/* Back button (step 1 consent screen) */
.stepNavLeft {
margin-top: 0.75rem;
display: flex;
}
.navBack {
background: none;
border: none;
padding: 0.25rem 0;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
cursor: pointer;
text-decoration: underline;
}
.navBack:hover {
color: var(--text-primary);
}
/* Cost estimate hint */
.costHint {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
background: var(--info-bg, #eff6ff);
border: 1px solid var(--info-border, #bfdbfe);
border-radius: 8px;
font-size: 0.8125rem;
}
.costHintIcon {
flex-shrink: 0;
margin-top: 2px;
color: var(--info-color, #3b82f6);
}
.costHint > div {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.costHintTitle {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.125rem;
}
.costTable {
border-collapse: collapse;
width: 100%;
font-size: 0.8125rem;
}
.costLabel {
color: var(--text-secondary, #555);
padding-right: 1rem;
white-space: nowrap;
}
.costVal {
font-weight: 600;
color: var(--info-color, #1d4ed8);
}
.costRowNeut .costLabel,
.costRowNeut .costVal {
padding-top: 0.125rem;
}
.costRowNeut .costVal {
color: #b45309;
}
.costHintWarn {
font-size: 0.75rem;
color: #b45309;
font-weight: 500;
line-height: 1.4;
}
.costHintNote {
color: var(--text-secondary, #555);
font-size: 0.75rem;
}
:global(.dark-theme) .costHint {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.3);
}
:global(.dark-theme) .costVal {
color: #93c5fd;
}
:global(.dark-theme) .costRowNeut .costVal,
:global(.dark-theme) .costHintWarn {
color: #fbbf24;
}
/* Navigation */
.stepNav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: 0.5rem;
gap: 0.75rem;
}
.navBack {
padding: 0.5rem 1rem;
background: var(--surface-color);
color: var(--text-secondary, #666);
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.navBack:hover {
background: var(--bg-secondary, #f5f5f5);
}
.navNext {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.25rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.navNext:hover {
background: var(--primary-dark, #d94d3a);
}
.navConnect {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.5rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.navConnect:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.navConnect:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Dark theme */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);
}
:global(.dark-theme) .prefSelect,
:global(.dark-theme) .prefNumber {
background: var(--surface-color);
color: var(--text-primary);
}
:global(.dark-theme) .summary {
border-color: var(--border-color);
}
:global(.dark-theme) .summaryRow {
border-color: var(--border-color);
}

View file

@ -0,0 +1,520 @@
/**
* AddConnectionWizard
*
* Multi-step modal for adding a new connector with optional knowledge
* ingestion consent and per-connection preferences (§2.6).
*
* Steps:
* 0 Connector wählen
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
*/
import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import styles from './AddConnectionWizard.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup';
interface WizardState {
step: 0 | 1 | 2 | 3;
connector: ConnectorType | null;
knowledgeEnabled: boolean;
prefs: KnowledgePreferences;
}
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
};
// ---------------------------------------------------------------------------
// Cost estimate helper
// ---------------------------------------------------------------------------
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface AddConnectionWizardProps {
open: boolean;
onClose: () => void;
onConnect: (
type: ConnectorType,
knowledgeEnabled: boolean,
prefs: KnowledgePreferences | null,
) => Promise<void>;
isConnecting?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
isConnecting = false,
}) => {
const [state, setState] = useState<WizardState>({
step: 0,
connector: null,
knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS },
});
const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
const handleClose = () => {
reset();
onClose();
};
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step }));
const setConnector = (connector: ConnectorType) =>
setState(s => ({ ...s, connector, step: 1 }));
const setKnowledgeEnabled = (v: boolean) =>
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 }));
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) =>
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const handleConnect = async () => {
if (!state.connector) return;
await onConnect(
state.connector,
state.knowledgeEnabled,
state.knowledgeEnabled ? state.prefs : null,
);
reset();
onClose();
};
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return (
<Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */}
<div className={styles.stepper}>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className={[
styles.stepDot,
state.step === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')}
>
{state.step > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step 0: Connector ---- */}
{state.step === 0 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => setConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
</button>
))}
</div>
</div>
)}
{/* ---- Step 1: Consent ---- */}
{state.step === 1 && (
<div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
</p>
<p className={styles.stepHint}>
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
</p>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)}
>
<FaCheck /> Ja, aufnehmen
</button>
<button
type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
Zurück
</button>
</div>
</div>
)}
{/* ---- Step 2: Preferences ---- */}
{state.step === 2 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
checked={!!state.prefs.neutralizeBeforeEmbed}
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Wissensdatenbank</span>
<span className={styles.summaryVal}>
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
</span>
</div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}>
<button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button
type="button"
className={styles.navConnect}
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default AddConnectionWizard;

View file

@ -6,7 +6,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
import type { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
export interface Automation2DataFlowContextValue {
currentNodeId: string;
@ -17,8 +17,15 @@ export interface Automation2DataFlowContextValue {
language: string;
portTypeCatalog: Record<string, PortSchema>;
systemVariables: Record<string, SystemVariable>;
/** Canonical form field types from the API — maps UI type id to portType primitive. */
formFieldTypes: FormFieldType[];
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
getAvailableSourceIds: () => string[];
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
instanceId?: string;
request?: ApiRequestFunction;
/** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
}
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
@ -36,6 +43,9 @@ interface Automation2DataFlowProviderProps {
language: string;
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
instanceId?: string;
request?: ApiRequestFunction;
children: React.ReactNode;
}
@ -48,10 +58,58 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
language,
portTypeCatalog = {},
systemVariables = {},
formFieldTypes = [],
instanceId,
request,
children,
}) => {
const value = useMemo((): Automation2DataFlowContextValue | null => {
if (!node) return null;
const formTypeToPort: Record<string, string> = Object.fromEntries(
formFieldTypes.map((f) => [f.id, f.portType])
);
const resolvePortType = (rawType: string): string => formTypeToPort[rawType] ?? rawType;
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
const raw = node.parameters?.[parameterKey];
if (!Array.isArray(raw)) return null;
const fields: PortField[] = [];
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue;
const rec = item as Record<string, unknown>;
if (typeof rec.name !== 'string') continue;
const lab = rec.label;
const desc =
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
if (rawType === 'group' && Array.isArray(rec.fields)) {
for (const sub of rec.fields as Record<string, unknown>[]) {
if (!sub || typeof sub.name !== 'string') continue;
const sl = sub.label;
const sdesc =
typeof sl === 'string'
? sl
: typeof sl === 'object' && sl !== null
? String((sl as Record<string, string>).de ?? '')
: '';
fields.push({
name: `${rec.name}.${sub.name}`,
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
required: Boolean(sub.required),
});
}
continue;
}
fields.push({
name: rec.name,
type: resolvePortType(rawType),
description: (desc && desc.trim()) || rec.name,
required: Boolean(rec.required),
});
}
return fields.length ? { name: 'FormPayload_dynamic', fields } : null;
};
return {
currentNodeId: node.id,
nodes,
@ -61,11 +119,15 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
language,
portTypeCatalog,
systemVariables,
formFieldTypes,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
instanceId,
request,
parseGraphDefinedSchema,
};
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, instanceId, request]);
return (
<Automation2DataFlowContext.Provider value={value}>

View file

@ -256,6 +256,225 @@
background: var(--bg-primary, #fff);
}
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
.canvasHeaderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
}
@media (max-width: 900px) {
.canvasHeaderRow {
grid-template-columns: 1fr;
}
}
.canvasHeaderContext {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
/* Closed <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect {
flex: 0 0 auto;
width: 12.5rem;
max-width: 100%;
padding: 0.4rem 0.5rem;
min-height: 2rem;
font-size: 0.85rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderTitleBlock {
flex: 1 1 8rem;
min-width: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.canvasHeaderTitle,
.canvasHeaderTitle input {
margin: 0;
min-width: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.canvasHeaderTitle {
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvasHeaderTitleMuted {
font-style: italic;
font-weight: 500;
opacity: 0.65;
color: var(--text-secondary, #666);
}
.canvasHeaderTitle input {
width: 100%;
max-width: 100%;
padding: 0.25rem 0.4rem;
border: 1px solid var(--primary-color, #007bff);
border-radius: 4px;
outline: none;
background: var(--bg-primary, #fff);
box-sizing: border-box;
}
.canvasHeaderActionPanel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto;
max-width: 100%;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderActionPanel button {
margin-top: 0;
}
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */
.canvasHeaderRunButton {
min-width: 12.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
@media (max-width: 900px) {
.canvasHeaderActionPanel {
justify-content: flex-start;
}
}
.canvasHeaderVersionRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e8e8e8);
width: 100%;
}
.canvasHeaderVersionRow button {
margin-top: 0;
}
.canvasHeaderVersionLabel {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #666);
flex: 0 0 auto;
}
.canvasHeaderVersionSelect {
width: 11rem;
max-width: 100%;
padding: 0.3rem 0.45rem;
font-size: 0.85rem;
min-height: 1.9rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderSysadmin {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
padding: 0.2rem 0.45rem;
border: 1px dashed var(--border-color, #ccc);
border-radius: 4px;
cursor: pointer;
user-select: none;
white-space: nowrap;
flex: 0 0 auto;
}
.canvasHeaderNewSplit {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.canvasHeaderSplitPair {
display: flex;
flex: 0 0 auto;
}
.canvasHeaderNewSplitMain {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.canvasHeaderNewSplitMenu {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 0.25rem;
padding-right: 0.4rem;
border-left: 1px solid rgba(0, 0, 0, 0.12);
}
.canvasHeaderMenuDropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
min-width: 11rem;
margin-top: 0.25rem;
}
.canvasHeaderMenuItem {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-primary, #333);
}
.canvasHeaderMenuItem:hover {
background: var(--bg-hover, #e9ecef);
}
.canvasHeaderMenuItem + .canvasHeaderMenuItem {
border-top: 1px solid var(--border-color, #e0e0e0);
}
.canvasTitle {
margin: 0;
font-size: 0.875rem;
@ -507,20 +726,32 @@
cursor: copy;
}
/* Node Config Panel */
/* Node Config Panel
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
* pair acts as a safety net so long unbreakable strings (type names like
* `List[ActionDocument]`, hashed IDs, refs like ` node.path field`) can
* never push content out of the panel frame. Children rely on this; e.g.
* `RequiredAttributePicker` lays out label/badge so the badge wraps below
* a long label rather than escaping to the right.
*/
.nodeConfigPanel {
padding: 1rem;
background: var(--bg-primary, #fff);
border-left: 1px solid var(--border-color, #e0e0e0);
width: 280px;
flex-shrink: 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.nodeConfigPanel h4 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.nodeConfigNameRow {
@ -547,6 +778,8 @@
font-size: 0.75rem;
color: var(--text-secondary, #666);
line-height: 1.4;
overflow-wrap: anywhere;
word-break: break-word;
}
.nodeConfigPanel label {
@ -572,7 +805,8 @@
min-height: 60px;
}
/* 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). */
.nodeConfigPanel
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) {
margin-top: 0.5rem;
@ -1284,53 +1518,112 @@
min-width: 0;
}
/* Data Picker */
/* Data Picker rendered with createPortal(document.body) so it is not affected
by .nodeConfigPanels generic CTA `button` styles. */
.dataPickerOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 11000;
padding: 1rem;
box-sizing: border-box;
}
.dataPickerModal {
background: var(--bg-primary, #fff);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 420px;
max-height: 80vh;
color: var(--text-primary, #1a1a1a);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid var(--border-color, #e0e0e0);
max-width: min(420px, 100vw - 2rem);
width: 100%;
max-height: min(80vh, 640px);
display: flex;
flex-direction: column;
min-height: 0;
}
.dataPickerHeader {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
padding: 1rem 1.25rem;
gap: 0.75rem;
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.dataPickerHeaderControls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.dataPickerTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
line-height: 1.35;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.4rem;
min-width: 0;
}
.dataPickerTypeBadge {
display: inline-block;
font-size: 0.7rem;
font-weight: 400;
font-family: ui-monospace, 'Cascadia Code', monospace;
color: var(--text-secondary, #666);
background: var(--bg-secondary, #f0f0f0);
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
padding: 0.1rem 0.45rem;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dataPickerStrictLabel {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--text-secondary, #666);
user-select: none;
}
.dataPickerClose {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary, #666);
padding: 0 0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #d0d0d0);
border-radius: 6px;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
color: var(--text-primary, #333);
}
.dataPickerClose:hover {
color: var(--text-primary, #333);
background: var(--bg-hover, #e9ecef);
color: var(--text-primary, #1a1a1a);
border-color: var(--border-color, #b8b8b8);
}
.dataPickerBody {
@ -1345,24 +1638,35 @@
}
.dataPickerNodeSection {
margin-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
/* Expandable source row: neutral “list row”, not a primary CTA. */
.dataPickerNodeHeader {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0;
background: none;
border: none;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
background: var(--bg-secondary, #f4f5f7);
border: 1px solid var(--border-color, #dde1e5);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-size: 0.85rem;
text-align: left;
color: var(--text-primary, #1a1a1a);
margin: 0;
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.dataPickerNodeHeader:hover {
background: var(--bg-hover, #f5f5f5);
border-radius: 4px;
background: var(--bg-hover, #e9ebef);
border-color: var(--border-color, #c8cfd6);
}
.dataPickerNodeHeader:focus-visible {
outline: 2px solid var(--primary-color, #4a6fa5);
outline-offset: 1px;
}
.dataPickerExpandIcon {
@ -1401,6 +1705,105 @@
border-color: var(--primary-color, #007bff);
}
/* Hover safety net: every nested span in a leaf inherits the white text so
* type-hints and meta info stay readable on the blue hover background. */
.dataPickerLeaf:hover * {
color: inherit;
}
/* Inline type-hint after a leaf label, e.g. "documents (List[ActionDocument])". */
.dataPickerLeafType {
color: var(--text-secondary, #666);
font-size: 10px;
margin-left: 4px;
}
/* Schema-name hint on the node-section header row. */
.dataPickerNodeSchemaHint {
color: var(--text-secondary, #666);
font-size: 10px;
margin-left: 4px;
}
/* Type-mismatch warning badge (⚠) — shown instead of hiding incompatible fields. */
.dataPickerMismatchBadge {
font-size: 10px;
margin-left: 4px;
color: var(--color-warning, #f59e0b);
flex-shrink: 0;
}
/* Recommended pick: subtle highlight on the row */
.dataPickerLeafRecommended {
font-weight: 500;
}
/* "Empfohlen" pill shown on recommended entries */
.dataPickerRecommendedPill {
display: inline-block;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 1px 5px;
border-radius: 10px;
margin-left: 5px;
background: var(--color-primary-light, #dbeafe);
color: var(--color-primary, #2563eb);
flex-shrink: 0;
vertical-align: middle;
}
/* "iterieren" affordance visually distinct (subtle accent), readable on
* the picker's white background and on the leaf's blue hover background. */
.dataPickerIterateBtn {
font-size: 10px;
padding: 2px 6px;
background: var(--bg-secondary, #f5f7fa);
color: var(--primary-color, #007bff);
border: 1px solid var(--border-color, #e0e0e0);
white-space: nowrap;
}
.dataPickerIterateBtn:hover {
background: var(--primary-color, #007bff);
color: #fff;
border-color: var(--primary-color, #007bff);
}
/* Curated picker: disclose technical / rare paths behind a single quiet control. */
.dataPickerCuratedToggle {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.38rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-secondary, #5c6370);
background: var(--bg-primary, #fff);
border: 1px dashed var(--border-color, #cfd4dc);
border-radius: 5px;
cursor: pointer;
text-align: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.dataPickerCuratedToggle:hover {
color: var(--text-primary, #333);
background: var(--bg-secondary, #f4f6f8);
border-color: var(--border-color, #b8c0cc);
}
.dataPickerCuratedDivider {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary, #8a9199);
margin: 0.75rem 0 0.35rem 0;
padding-left: 0.15rem;
}
/* Dynamic Value Field */
.dynamicValueField {
display: flex;

View file

@ -22,6 +22,7 @@ import {
archiveVersion,
createTemplateFromWorkflow,
copyTemplate,
importWorkflowFromFile,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
@ -44,6 +45,8 @@ import {
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
@ -55,6 +58,8 @@ import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
@ -87,12 +92,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSourcesChanged,
}) => {
const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
@ -122,17 +129,36 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
instanceId,
mandateId: mandateId || '',
featureInstanceId: instanceId,
surface: 'graphEditor',
}), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
});
const [sidebarWidth, setSidebarWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
});
// Verbose schema toggle: shows the static type-reference block (input/output
// schema) and parameter type-badges in NodeConfigPanel. Only the
// CanvasHeader exposes the toggle (sysadmin-only); persisted to localStorage.
const [verboseSchema, setVerboseSchema] = useState(() => {
try { return localStorage.getItem('flowEditor.verboseSchema') === '1'; } catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('flowEditor.verboseSchema', verboseSchema ? '1' : '0'); } catch { /* ignore */ }
}, [verboseSchema]);
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
useEffect(() => {
@ -178,6 +204,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
);
// Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the
// canvas error badges and the Run-button gate. Graph-level: Save stays
// unconditional (Schicht-4 invariant: WIP must always be persistable).
const nodeErrors = useMemo(
() =>
findGraphErrors(
canvasNodes,
nodeTypes,
(p) => getParamLabel(p.description, language) || p.name,
),
[canvasNodes, nodeTypes, language]
);
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
@ -209,6 +250,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
return;
}
// Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen.
if (Object.keys(nodeErrors).length > 0) {
const firstId = Object.keys(nodeErrors)[0];
const firstNode = canvasNodes.find((n) => n.id === firstId);
if (firstNode) setSelectedNode(firstNode);
setExecuteResult({
success: false,
error:
t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') +
(firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''),
});
return;
}
setExecuting(true);
setExecuteResult(null);
try {
@ -226,7 +280,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
@ -234,11 +288,28 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
return;
}
// Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt,
// aber wir berichten die Anzahl in einem nicht-blockierenden Warning,
// damit der User die WIP-Lücken nicht stillschweigend persistiert.
const errorCount = Object.values(nodeErrors).reduce(
(acc, list) => acc + list.length,
0,
);
const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => ({
success: true,
warning:
errorCount > 0
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
: undefined,
});
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse);
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
setExecuteResult(_buildSaveResult());
} else {
const label = await promptInput(t('Workflow-Name:'), {
title: t('Workflow speichern'),
@ -253,18 +324,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
targetFeatureInstanceId,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]);
setExecuteResult({ success: true } as ExecuteGraphResponse);
setExecuteResult(_buildSaveResult());
}
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
const handleLoad = useCallback(
async (workflowId: string) => {
@ -275,6 +347,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
}
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
setWorkflows((prev) => {
const idx = prev.findIndex((w) => w.id === workflowId);
if (idx === -1) return [...prev, wf];
@ -387,6 +460,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setRegistryCatalog(data.portTypeCatalog as never);
}
if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]);
@ -601,14 +675,27 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[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]);
}, [request, instanceId, showError, t]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
@ -662,9 +749,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
selectedNode.type.startsWith(p)
);
[
'input.',
'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
return (
<div className={styles.container}>
@ -722,6 +820,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
hideTabs={['chats']}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}
onWorkflowImportedFromFile={async (workflowId) => {
await loadWorkflows();
handleWorkflowSelect(workflowId);
}}
/>
)}
</div>
@ -743,6 +845,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeBlockedReason={
hasGraphErrors
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
: null
}
onExecuteBlockedClick={() => {
if (firstErrorNodeId) {
const n = canvasNodes.find((x) => x.id === firstErrorNodeId);
if (n) setSelectedNode(n);
}
}}
executeResult={executeResult}
versions={versions}
currentVersionId={currentVersionId}
@ -757,6 +870,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@ -771,6 +889,22 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
getCategoryIcon={getCategoryIcon}
onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors}
onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow' || !instanceId) return false;
const p = payload as { files?: Array<{ id: string }> } | undefined;
const fileId = p?.files?.[0]?.id;
if (!fileId) return false;
try {
const result = await importWorkflowFromFile(request, instanceId, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true;
} catch (e) {
console.error(`${LOG} workflow drop import failed`, e);
return false;
}
}}
/>
</div>
{configurableSelected && selectedNode && (
@ -783,6 +917,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language}
portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes}
instanceId={instanceId}
request={request}
>
<NodeConfigPanel
node={selectedNode}
@ -793,6 +930,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNodeUpdate={handleNodeUpdate}
instanceId={instanceId}
request={request}
verboseSchema={verboseSchema}
/>
</Automation2DataFlowProvider>
)}

View file

@ -0,0 +1,99 @@
// 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

@ -8,6 +8,12 @@ import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTempla
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
interface TargetInstanceOption {
id: string;
label: string;
}
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
@ -21,6 +27,11 @@ interface CanvasHeaderProps {
saving: boolean;
executing: boolean;
hasNodes: boolean;
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
* parent can navigate the user to the first offending node. */
executeBlockedReason?: string | null;
onExecuteBlockedClick?: () => void;
executeResult: ExecuteGraphResponse | null;
versions?: AutoVersion[];
currentVersionId?: string | null;
@ -35,6 +46,13 @@ interface CanvasHeaderProps {
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
onAutoLayout?: () => void;
/** Sysadmin-only: when true, NodeConfigPanel renders the static
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -56,6 +74,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
saving,
executing,
hasNodes,
executeBlockedReason,
onExecuteBlockedClick,
executeResult,
versions,
currentVersionId,
@ -70,8 +90,14 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
verboseSchema,
onVerboseSchemaChange,
targetFeatureInstanceId,
onTargetInstanceChange,
targetInstanceOptions,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
const statusBadge = _getStatusBadge(t);
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
@ -130,35 +156,59 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
[t]
);
const _titleHint =
onWorkflowRename && currentWorkflow
? `${currentWorkflow.label}${t('Klicken zum Umbenennen')}`
: currentWorkflow?.label;
return (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{/* Workflow name: inline editable */}
<div className={styles.canvasHeaderRow}>
<div className={styles.canvasHeaderContext}>
<select
className={styles.canvasHeaderWorkflowSelect}
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value ? e.target.value : null;
onWorkflowSelect(id);
}}
aria-label={t('Workflow laden')}
title={t('Workflow laden')}
>
<option value="">{t('Workflow laden')}</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<div className={styles.canvasHeaderTitleBlock}>
{currentWorkflowId && currentWorkflow ? (
editingName ? (
<input
ref={nameInputRef}
className={styles.canvasHeaderTitle}
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={_commitNameEdit}
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }}
/>
) : (
<h4
className={styles.canvasTitle}
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
className={styles.canvasHeaderTitle}
style={{ cursor: onWorkflowRename ? 'pointer' : 'default' }}
onClick={_startNameEdit}
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
title={_titleHint}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
<h4 className={`${styles.canvasHeaderTitle} ${styles.canvasHeaderTitleMuted}`}>
{t('Neuer Workflow')}
</h4>
)}
</div>
{onWorkflowSettings && (
<button
type="button"
@ -170,37 +220,60 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
<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>
{/* Split "Neu" button */}
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<div style={{ display: 'flex' }}>
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
<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}
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
title={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
</div>
{newMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNew(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
role="menuitem"
>
{t('Leerer Workflow')}
</button>
{onNewFromTemplate && (
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
@ -213,7 +286,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
type="button"
className={styles.retryButton}
onClick={onSave}
disabled={saving || !hasNodes}
disabled={saving}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
>
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
@ -231,26 +305,28 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</button>
)}
{/* Save as template */}
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<button
type="button"
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
disabled={templateSaving}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
</button>
{templateMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
<div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => (
<button
key={s}
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
role="menuitem"
>
{scopeLabels[s]}
</button>
@ -259,35 +335,44 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
)}
</div>
)}
<select
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value ? e.target.value : null;
onWorkflowSelect(id);
}}
style={{ padding: '0.4rem', minWidth: 180 }}
>
<option value="">{t('Workflow laden')}</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<button
type="button"
className={styles.retryButton}
onClick={onExecute}
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={{ marginRight: '0.5rem', display: 'inline-block' }} />
<FaSpinner className={styles.spinner} style={{ flexShrink: 0 }} />
{t('Ausführen…')}
</>
) : executeBlockedReason ? (
<>
<FaPlay style={{ opacity: 0.5, flexShrink: 0 }} />
{t('Pflicht-Felder fehlen')}
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
<FaPlay style={{ flexShrink: 0 }} />
{t('Ausführen')}
</>
)}
@ -298,17 +383,32 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
{t('Workspace')}
</button>
)}
{_isSysAdmin && onVerboseSchemaChange && (
<label
className={styles.canvasHeaderSysadmin}
title={t('Sysadmin-Ansicht: zeigt im Node-Panel das statische Typ-Schema (Eingabe/Ausgabe) und Parameter-Typ-Badges.')}
>
<input
type="checkbox"
checked={!!verboseSchema}
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
style={{ margin: 0 }}
/>
{t('Schema-Details')}
</label>
)}
</div>
</div>
{/* Version Selector */}
{currentWorkflowId && versions && versions.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>{t('Version:')}</span>
<div className={styles.canvasHeaderVersionRow}>
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
<select
className={styles.canvasHeaderVersionSelect}
value={currentVersionId ?? ''}
onChange={(e) => onVersionSelect?.(e.target.value || null)}
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
disabled={versionLoading}
aria-label={t('Version')}
>
<option value="">{t('Aktuelle')}</option>
{versions.map((v) => (
@ -392,19 +492,27 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? 'rgba(40,167,69,0.15)'
? 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
? 'var(--success-color,#28a745)'
? 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.warning ? (
<> {executeResult.warning}</>
) : (
<>{t('Ausführung abgeschlossen')}</>
)
) : (executeResult as { paused?: boolean }).paused ? (
<>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den

View file

@ -4,7 +4,7 @@
* AI Chat sidebar for the GraphicalEditor.
* Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput:
* - Files: drag & drop from FolderTree onto input area, or click in UDB
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
@ -32,7 +32,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
export interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
itemType?: 'file' | 'group';
}
export interface EditorDataSource {
@ -241,7 +241,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
}, [_handleSend]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) {
if (
e.dataTransfer.types.includes('application/tree-items') ||
e.dataTransfer.types.includes('application/group-id') ||
e.dataTransfer.types.includes('application/file-id') ||
e.dataTransfer.types.includes('application/file-ids')
) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true);
@ -252,6 +257,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
const _handleDrop = useCallback((e: React.DragEvent) => {
setTreeDropOver(false);
const groupId = e.dataTransfer.getData('application/group-id');
if (groupId) {
e.preventDefault();
e.stopPropagation();
return;
}
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
e.preventDefault();
@ -282,11 +293,11 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
<span key={pf.fileId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
}}>
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{onRemovePendingFile && (
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,

View file

@ -48,7 +48,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
const list = q
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
: [...workflows];
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
return list;
}, [workflows, search]);
@ -85,7 +85,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
) : (
filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId;
const ts = wf.lastStartedAt || wf.createdAt;
const ts = wf.lastStartedAt || wf.sysCreatedAt;
return (
<div
key={wf.id}

View file

@ -4,7 +4,7 @@
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { NodeType } from '../../../api/workflowApi';
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -23,7 +23,7 @@ export interface CanvasNode {
outputs: number;
parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
}
export interface CanvasConnection {
@ -108,6 +108,12 @@ export function computeAutoLayout(
});
}
function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string {
if (typeof schema === 'string') return schema;
if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload';
return '';
}
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
function _checkConnectionCompatibility(
sourceNode: CanvasNode,
@ -124,11 +130,12 @@ function _checkConnectionCompatibility(
const tgtPort = tgtType.inputPorts[targetInputIdx];
if (!srcPort || !tgtPort) return 'ok';
const srcSchema = srcPort.schema;
const srcSchema = _outputSchemaName(srcPort.schema as string | GraphDefinedSchemaRef);
const accepts = tgtPort.accepts;
if (!accepts || accepts.length === 0) return 'ok';
if (accepts.includes('Transit')) return 'ok';
if (accepts.includes(srcSchema)) return 'ok';
if (srcSchema && accepts.includes(srcSchema)) return 'ok';
if (srcSchema?.startsWith('FormPayload') && accepts.includes('FormPayload')) return 'ok';
return 'warning';
}
@ -143,6 +150,14 @@ interface FlowCanvasProps {
getCategoryIcon: (category: string) => React.ReactNode;
onSelectionChange?: (node: CanvasNode | null) => void;
highlightedNodeIds?: Record<string, string>;
/** Phase-4: per-node "required-but-unbound" param errors. The canvas renders
* a red error badge in the top-right of each node whose id is a key. */
nodeErrors?: Record<string, Array<{ paramName: string; paramLabel: string }>>;
/** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt
* (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
* Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */
onExternalDrop?: (mime: string, payload: unknown) => Promise<boolean> | boolean;
}
const HIGHLIGHT_COLORS: Record<string, string> = {
@ -162,6 +177,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
getCategoryIcon,
onSelectionChange,
highlightedNodeIds,
nodeErrors,
onExternalDrop,
}) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
@ -256,8 +273,32 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
}, [connections]);
const handleDrop = useCallback(
(e: React.DragEvent) => {
async (e: React.DragEvent) => {
e.preventDefault();
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
if (onExternalDrop) {
const reservedMimes = new Set([
'application/json',
'application/tree-items',
'application/group-file-ids',
'application/file-id',
'application/file-ids',
'application/group-id',
]);
for (const mime of Array.from(e.dataTransfer.types)) {
if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue;
const raw = e.dataTransfer.getData(mime);
if (!raw) continue;
try {
const payload = JSON.parse(raw);
const handled = await onExternalDrop(mime, payload);
if (handled) return;
} catch {
// andere Drag-Source → ignorieren, Standard-Pfad versuchen
}
}
}
// 2) Standard: Node-Type aus der NodeSidebar
const raw = e.dataTransfer.getData('application/json');
if (!raw || !containerRef.current) return;
try {
@ -269,7 +310,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
} catch (_) {}
},
[onDropNodeType, panOffset, zoom]
[onDropNodeType, onExternalDrop, panOffset, zoom]
);
const handleHandleMouseDown = useCallback(
@ -761,6 +802,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
const wireSourceNode =
connectingFrom && !selectedConnectionId ? nodes.find((n) => n.id === connectingFrom.nodeId) : null;
const isSelected = selectedNodeIds.has(node.id);
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
const displayTitle = node.title ?? node.label ?? getLabel(node);
@ -805,15 +849,54 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
{nodeErrors?.[node.id]?.length ? (
<div
role="status"
title={
t('Pflicht-Felder ohne Quelle: ') +
nodeErrors[node.id].map((e) => e.paramLabel).join(', ')
}
style={{
position: 'absolute',
top: -8,
right: -8,
minWidth: 20,
height: 20,
borderRadius: 10,
padding: '0 6px',
background: 'var(--danger-color, #dc3545)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
zIndex: 5,
pointerEvents: 'auto',
}}
>
{nodeErrors[node.id].length}
</div>
) : null}
{handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index);
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
const isCurrentTargetOfSelection =
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
let wireTargetOk = true;
if (!isOutput && connectingFrom && !selectedConnectionId && wireSourceNode) {
const sourceOutputIdx =
connectingFrom.handleIndex >= wireSourceNode.inputs
? connectingFrom.handleIndex - wireSourceNode.inputs
: 0;
wireTargetOk =
_checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
}
const canConnect =
isOutput ||
(!used && connectingFrom) ||
(!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) ||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
const nt = nodeTypeMap[node.type];
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;

View file

@ -3,12 +3,15 @@
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas';
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../api/workflowApi';
import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -22,6 +25,9 @@ interface NodeConfigPanelProps {
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
instanceId?: string;
request?: ApiRequestFunction;
/** When true, render developer-oriented sections (Schema-Typ-Referenz,
* parameter type-badges). Toggle in CanvasHeader, sysadmin-only. */
verboseSchema?: boolean;
}
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
@ -32,6 +38,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
onNodeUpdate,
instanceId,
request,
verboseSchema = false,
}) => {
const { t } = useLanguage();
const [params, setParams] = useState<Record<string, unknown>>({});
@ -72,11 +79,53 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[onParametersChange]
);
const dataFlow = useAutomation2DataFlow();
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
// nicht nach unten scrollen muss, um zu sehen was fehlt.
const sortedParameters: NodeTypeParameter[] = useMemo(() => {
const all = nodeType?.parameters ?? [];
const required = all.filter((p) => p.required);
const optional = all.filter((p) => !p.required);
return [...required, ...optional];
}, [nodeType?.parameters]);
// Pre-compute which required params are unbound on this node so we can
// surface a panel-level summary banner. The hidden-param safety net lives
// inside `findRequiredErrors` so banner, canvas badges and Run-button stay
// in lockstep.
// Banner labels are kept short (`param.name`); the full description is
// attached as the tooltip below.
const requiredErrors = useMemo(() => {
if (!node || !nodeType) return [];
return findRequiredErrors(node, nodeType, (p) => p.name);
}, [node, nodeType]);
// Resolve full descriptions per missing param (for the banner tooltip).
const requiredErrorTooltip = useMemo(() => {
if (!requiredErrors.length || !nodeType) return '';
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
return requiredErrors
.map((e) => {
const p = byName.get(e.paramName);
const desc = p ? (getLabel(p.description, language) || '') : '';
return desc ? `${e.paramName}: ${desc}` : e.paramName;
})
.join('\n');
}, [requiredErrors, nodeType, language]);
if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.');
const showNameField = onNodeUpdate && !isTrigger;
const parameters = nodeType.parameters || [];
const parameters = sortedParameters;
const inputPortDefs = nodeType.inputPorts ?? {};
const outputPortDefs = nodeType.outputPorts ?? {};
const inputPortEntries = Object.entries(inputPortDefs);
const outputPortEntries = Object.entries(outputPortDefs);
const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0;
return (
<div className={styles.nodeConfigPanel}>
@ -101,12 +150,136 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
{getLabel(nodeType.description, language)}
</p>
)}
{hasPortInfo && verboseSchema && (
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
<summary
style={{
cursor: 'pointer',
color: 'var(--text-secondary)',
fontWeight: 500,
padding: '0.15rem 0',
fontStyle: 'italic',
}}
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
>
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')}
</summary>
{inputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B07'} {t('Eingabe')}
</div>
{inputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`in-${idx}`}
portIndex={Number(idx)}
schemaNames={def?.accepts ?? []}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
{outputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B06'} {t('Ausgabe')}
</div>
{outputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`out-${idx}`}
portIndex={Number(idx)}
schemaNames={_schemaNamesFromOutputPort(def)}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
</details>
)}
{requiredErrors.length > 0 && (
<div
style={{
marginBottom: 8,
padding: '6px 10px',
background: 'rgba(220,53,69,0.10)',
borderLeft: '3px solid var(--danger-color, #dc3545)',
borderRadius: 4,
fontSize: 12,
color: 'var(--danger-color, #dc3545)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
title={requiredErrorTooltip || undefined}
>
{t('Pflicht-Felder ohne Quelle:')}{' '}
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div>
)}
{parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={params[param.name] ?? param.default}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
);
}
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
key={param.name}
param={param}
value={params[param.name] ?? param.default}
onChange={(val: unknown) => updateParam(param.name, val)}
@ -115,6 +288,108 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
request={request}
nodeType={node.type}
/>
</div>
);
})}
</div>
);
};
/** Heuristic: required params with a Schicht-1 catalog type (non-primitive
* ref/record/list) get the typed RequiredAttributePicker; primitive scalars
* fall through to the legacy frontend-type renderer (text/number/select etc.)
* unless they have no frontendType at all and a non-trivial type. */
function _shouldUseRequiredPicker(param: NodeTypeParameter): boolean {
if (!param.required) return false;
if (!param.type) return false;
// Hidden params never get a picker — they are system-set or rendered to
// nothing on purpose. The render loop above also skips hidden rows entirely.
if (param.frontendType === 'hidden') return false;
// Always defer to specialized FE renderers when explicitly chosen.
if (param.frontendType && _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS.has(param.frontendType)) {
return false;
}
// Catalog ref/record/list types are best handled by RequiredAttributePicker.
if (/^(List\[|Dict\[)/.test(param.type)) return true;
if (/^[A-Z]/.test(param.type)) return true;
return false;
}
const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'userConnection',
'featureInstance',
'sharepointFolder',
'sharepointFile',
'userFileFolder',
'clickupList',
'clickupTask',
'dataRef',
'caseList',
'fieldBuilder',
'keyValueRows',
'cron',
'condition',
'mappingTable',
'filterExpression',
'attachmentBuilder',
'json',
'modelMultiSelect',
]);
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
if (!def?.schema) return [];
if (typeof def.schema === 'string') return [def.schema];
if (typeof def.schema === 'object' && def.schema.kind === 'fromGraph') return ['FormPayload', 'FormPayload_dynamic'];
return [];
}
interface _PortFieldListProps {
portIndex: number;
schemaNames: string[];
catalog: Record<string, PortSchema>;
emptyLabel: string;
language: string;
}
const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => {
if (!schemaNames.length) return null;
return (
<div style={{ marginLeft: 4, marginBottom: 4 }}>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.7rem' }}>
{`#${portIndex} `}{schemaNames.join(' | ')}
</div>
{schemaNames.map((name) => {
const schema = catalog[name];
const fields = schema?.fields ?? [];
if (name === 'Transit') {
return (
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontStyle: 'italic', fontSize: '0.7rem' }}>
{'\u00B7 Transit (durchgereichte Daten)'}
</div>
);
}
if (!fields.length) {
return (
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontSize: '0.7rem' }}>
{`\u00B7 ${emptyLabel}`}
</div>
);
}
return (
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
{fields.map((f) => (
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: 'var(--text-secondary)' }}>
<span style={{ fontFamily: 'monospace', color: 'var(--text-primary)' }}>{f.name}</span>
<span style={{ color: 'var(--text-tertiary)' }}>{`: ${f.type}`}</span>
{!f.required && <span style={{ color: 'var(--text-tertiary)' }}>{' (optional)'}</span>}
{f.description && (
<div style={{ color: 'var(--text-secondary)', marginLeft: 4 }}>
{getLabel(f.description, language)}
</div>
)}
</li>
))}
</ul>
);
})}
</div>

View file

@ -2,45 +2,22 @@
* Form node config - draggable fields, types, required toggle
*/
import React, { useEffect, useState } from 'react';
import React from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
}) => {
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = (params.fields as FormField[]) ?? [];
const [connections, setConnections] = useState<UserConnection[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false);
useEffect(() => {
if (!instanceId || !request) {
setConnections([]);
return;
}
let cancelled = false;
setConnectionsLoading(true);
fetchConnections(request, instanceId)
.then((rows) => {
if (!cancelled) setConnections(rows.filter((c) => c.authority === 'clickup'));
})
.catch(() => {
if (!cancelled) setConnections([]);
})
.finally(() => {
if (!cancelled) setConnectionsLoading(false);
});
return () => {
cancelled = true;
};
}, [instanceId, request]);
const moveField = (fromIndex: number, toIndex: number) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
@ -108,33 +85,17 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
</div>
<div className={styles.formFieldRowFooter}>
<select
value={f.type ?? 'string'}
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
const fieldType = e.target.value;
next[i] = {
...next[i],
type: fieldType,
...(fieldType === 'clickup_tasks'
? { clickupStatusOptions: undefined }
: fieldType === 'clickup_status'
? { clickupConnectionId: undefined, clickupListId: undefined }
: {
clickupConnectionId: undefined,
clickupListId: undefined,
clickupStatusOptions: undefined,
}),
};
next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required };
updateParam('fields', next);
}}
style={{ width: 'auto', minWidth: 90 }}
>
<option value="string">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="date">{t('Datum')}</option>
<option value="boolean">{t('Kontrollkästchen')}</option>
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
</select>
<label className={styles.formFieldRequiredLabel}>
<input
@ -157,72 +118,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<FaTimes />
</button>
</div>
{f.type === 'clickup_status' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
<p style={{ margin: '0 0 6px' }}>
{t(
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
{ count: String(f.clickupStatusOptions.length) }
)}
</p>
) : (
<p style={{ margin: '0 0 6px' }}>
{t(
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
)}
</p>
)}
</div>
) : null}
{f.type === 'clickup_tasks' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
{t('ClickUp-Verbindung')}
</label>
<select
value={f.clickupConnectionId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupConnectionId: e.target.value };
updateParam('fields', next);
}}
disabled={connectionsLoading || !instanceId}
style={{ width: '100%', marginBottom: 8 }}
>
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
</label>
<input
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
value={f.clickupListId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupListId: e.target.value };
updateParam('fields', next);
}}
style={{ width: '100%' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
</p>
</div>
) : null}
</div>
))}
<button
type="button"
onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
updateParam('fields', [...fields, { name: '', type: 'text', label: '', required: false }])
}
>
+ {t('Feld')}

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,181 @@
/**
* ContextBuilderRenderer multi-select context binding for AI nodes.
*
* Renders a list of DataRef entries (each pointing to an upstream node's output
* path). On execution the backend serialises each ref, joins them with double
* newlines and prepends the result to the AI prompt.
*
* Stored value shape:
* [ { type: "ref", nodeId: "...", path: [...], expectedType: "..." }, ]
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
function isRefEntry(v: unknown): v is DataRef {
return isRef(v);
}
function toRefList(raw: unknown): DataRef[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw.filter(isRefEntry);
if (isRefEntry(raw)) return [raw];
return [];
}
const CHIP_STYLE: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '3px 6px 3px 10px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginBottom: 4,
};
const REMOVE_BTN: React.CSSProperties = {
padding: '0 5px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
marginLeft: 'auto',
};
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
const dragIndex = React.useRef<number | null>(null);
const entries = toRefList(value);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const getRefLabel = (ref: DataRef): string => {
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 addRef = (picked: DataRef | SystemVarRef) => {
if (!isRefEntry(picked)) return;
const alreadyIn = entries.some(
(e) => e.nodeId === picked.nodeId && e.path.join('.') === picked.path.join('.'),
);
if (!alreadyIn) {
onChange([...entries, picked]);
}
setPickerOpen(false);
};
const removeRef = (index: number) => {
const next = entries.filter((_, i) => i !== index);
onChange(next.length ? next : undefined);
};
const moveRef = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;
const next = [...entries];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{entries.length > 0 && (
<div style={{ marginBottom: 4 }}>
{entries.map((ref, i) => (
<div
key={`${ref.nodeId}-${ref.path.join('.')}`}
style={{ ...CHIP_STYLE, cursor: 'grab' }}
draggable
onDragStart={() => { dragIndex.current = i; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => {
if (dragIndex.current != null) moveRef(dragIndex.current, i);
dragIndex.current = null;
}}
onDragEnd={() => { dragIndex.current = null; }}
>
<span style={{ flex: 1, color: '#2d6a2d' }}>
{getRefLabel(ref)}
</span>
<button type="button" style={REMOVE_BTN} onClick={() => removeRef(i)} title={t('Entfernen')}>
×
</button>
</div>
))}
</div>
)}
{entries.length === 0 && (
<div
style={{
padding: '4px 8px',
background: '#f8f8f8',
border: '1px dashed #ccc',
borderRadius: 4,
fontSize: 11,
color: '#888',
marginBottom: 4,
}}
>
{t('Noch keine Quellen gewählt — wähle Daten aus vorherigen Schritten.')}
</div>
)}
<button
type="button"
onClick={() => setPickerOpen(true)}
disabled={!hasSources}
style={{
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 hinzufügen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={addRef}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

View file

@ -0,0 +1,168 @@
/**
* DataRefRenderer Pick-not-Push attribute binding using the existing
* hierarchical DataPicker.
*
* For required typed parameters (e.g. ``documentList: DocumentList``) where
* the user must explicitly bind to an upstream node's typed output. Replaces
* the legacy ``frontendType: "hidden"`` so the binding becomes visible and
* editable directly in the node config panel.
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
const currentRef = isRef(value) ? (value as DataRef) : null;
const isMissing = param.required && !currentRef;
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const currentNodeLabel = currentRef
? dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === currentRef.nodeId) ?? { id: currentRef.nodeId },
) ?? currentRef.nodeId
: null;
const onPick = (picked: DataRef | SystemVarRef) => {
onChange(picked);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
{param.type && (
<span
style={{
marginLeft: 6,
fontFamily: 'monospace',
fontWeight: 500,
fontSize: 10,
color: '#666',
background: '#eef',
padding: '0 4px',
borderRadius: 3,
}}
title={t('Erwarteter Typ')}
>
{param.type}
</span>
)}
</label>
{currentRef && (
<div
style={{
padding: '4px 8px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginBottom: 4,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
title={t('Aktive DataRef-Bindung')}
>
<span style={{ color: '#3c763d', fontWeight: 700 }}>{'\u2190'}</span>
<span style={{ fontFamily: 'monospace', color: '#3c763d', flex: 1 }}>
{currentNodeLabel}
{currentRef.path.length > 0 && (
<>
<span style={{ color: '#999' }}>{' \u2192 '}</span>
{currentRef.path.map((p) => String(p)).join('.')}
</>
)}
</span>
{currentRef.expectedType && (
<span style={{ fontSize: 10, color: '#666', fontFamily: 'monospace' }}>
{currentRef.expectedType}
</span>
)}
<button
type="button"
onClick={() => onChange(undefined)}
title={t('Bindung entfernen')}
style={{
padding: '0 6px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
}}
>
×
</button>
</div>
)}
{isMissing && (
<div
style={{
padding: '4px 8px',
background: '#fdecea',
border: '1px solid #d9534f',
borderRadius: 4,
fontSize: 12,
color: '#a94442',
marginBottom: 4,
}}
>
{t('Pflicht-Bindung fehlt — Quelle aus Upstream-Node wählen.')}
</div>
)}
<button
type="button"
onClick={() => setPickerOpen(true)}
disabled={!hasSources}
style={{
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: `1px solid ${isMissing ? '#d9534f' : currentRef ? '#5cb85c' : '#1c5fb5'}`,
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{!hasSources
? t('Keine vorherigen Nodes verfügbar')
: currentRef
? t('Bindung ändern …')
: t('Quelle wählen …')}
</button>
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={onPick}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

View file

@ -0,0 +1,158 @@
/**
* FeatureInstancePicker renderer for frontendType="featureInstance".
*
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
* GET /api/workflows/{instanceId}/options/feature.instance?featureCode=<code>
*
* Behavior matches the rest of the editor:
* - 0 results -> hint to create a feature instance for this mandate
* - 1 result -> auto-pick (no manual click required)
* - N results -> <select>
*
* The bound value is a plain `<id>` string so backend adapters can keep
* using `featureInstanceId` lookups unchanged. Type stays
* `FeatureInstanceRef[<code>]` on the parameter so DataPicker / RequiredAttributePicker
* filter correctly.
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import type { FieldRendererProps } from './index';
type FeatureInstanceOption = { id: string; label: string };
export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
instanceId,
request,
}) => {
const { t } = useLanguage();
const featureCode =
(param.frontendOptions?.featureCode as string | undefined) || undefined;
const [instances, setInstances] = React.useState<FeatureInstanceOption[]>([]);
const [loading, setLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
const autoSingleRef = React.useRef(false);
React.useEffect(() => {
if (!instanceId || !request || !featureCode) return;
setLoading(true);
setLoadError(null);
request({
url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
method: 'get',
})
.then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setInstances((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
})
.catch((err: unknown) => {
console.error('FeatureInstancePicker: failed to load instances', err);
setInstances([]);
setLoadError(err instanceof Error ? err.message : String(err));
})
.finally(() => setLoading(false));
}, [instanceId, request, featureCode]);
React.useEffect(() => {
if (instances.length !== 1 || autoSingleRef.current) return;
if (value !== '' && value !== undefined && value !== null) return;
autoSingleRef.current = true;
onChange(instances[0].id);
}, [instances, value, onChange]);
const strVal = typeof value === 'string' ? value : '';
const codeLabel = featureCode ?? t('Feature');
return (
<div style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}>
<label
style={{
display: 'block',
fontSize: 12,
marginBottom: 2,
color: 'var(--text-primary)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{param.description || param.name}
</label>
{loading && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Lade…')}</div>
)}
{!loading && instances.length === 0 && !loadError && (
<div
style={{
fontSize: 11,
color: 'var(--text-secondary)',
marginBottom: 4,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{t('Keine {code}-Instanz im aktiven Mandanten — bitte in der Admin-Konsole anlegen.', { code: codeLabel })}
</div>
)}
{!loading && instances.length === 1 && (
<div
style={{
fontSize: 12,
marginBottom: 4,
color: 'var(--text-primary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '4px 8px',
maxWidth: '100%',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
title={`${t('Einziger {code}-Mandant — automatisch gewählt.', { code: codeLabel })} — ${instances[0].label}`}
>
{instances[0].label}
</div>
)}
{!loading && instances.length > 1 && (
<select
value={strVal}
onChange={(e) => onChange(e.target.value)}
style={{
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
padding: '4px 8px',
borderRadius: 4,
border: '1px solid var(--border-color)',
background: 'var(--bg-primary)',
color: 'var(--text-primary)',
}}
>
<option value="">{t('{code}-Mandant wählen', { code: codeLabel })}</option>
{instances.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
)}
{loadError && (
<div
style={{
fontSize: 11,
color: 'var(--danger-color, #c00)',
marginTop: 2,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{t('Mandanten-Liste konnte nicht geladen werden')}
</div>
)}
</div>
);
};
export default FeatureInstancePicker;

View file

@ -0,0 +1,171 @@
/**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
*/
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../../editor/Automation2FlowEditor.module.css';
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
function _refToTemplateToken(ref: DataRef): string {
const pathSegs = (ref.path ?? []).map((p) => String(p));
if (pathSegs.length === 0) {
return `{{${ref.nodeId}}}`;
}
return `{{${ref.nodeId}.${pathSegs.join('.')}}}`;
}
function _insertAtCursor(
text: string,
insert: string,
start: number,
end: number,
): { next: string; caret: number } {
const next = text.slice(0, start) + insert + text.slice(end);
const caret = start + insert.length;
return { next, caret };
}
function _parseTokensInTemplate(
template: string,
nodes: Array<{ id: string; title?: string }>,
getNodeLabel: (n: { id: string; title?: string }) => string,
): Array<{ raw: string; label: string }> {
const out: Array<{ raw: string; label: string }> = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g');
while ((m = re.exec(template)) !== null) {
const inner = m[1].trim();
if (seen.has(inner)) continue;
seen.add(inner);
const parts = inner.split('.');
const nodeId = parts[0];
if (!nodeId) continue;
const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg));
const ref: DataRef = { type: 'ref', nodeId, path };
const label = formatRefLabel(ref, nodes, (id) =>
getNodeLabel(nodes.find((n) => n.id === id) ?? { id }),
);
out.push({ raw: m[0], label });
}
return out;
}
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const tokenLegend = useMemo(() => {
if (!dataFlow || !strVal.includes('{{')) return [];
return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel);
}, [strVal, dataFlow]);
const handlePick = useCallback(
(picked: DataRef | SystemVarRef) => {
if (isSystemVar(picked)) {
setPickerOpen(false);
return;
}
if (!isRef(picked)) {
setPickerOpen(false);
return;
}
const token = _refToTemplateToken(picked);
const el = textareaRef.current;
const start = el?.selectionStart ?? strVal.length;
const end = el?.selectionEnd ?? strVal.length;
const { next, caret } = _insertAtCursor(strVal, token, start, end);
onChange(next);
setPickerOpen(false);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
},
[onChange, strVal],
);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 6, marginBottom: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className={styles.startsInput}
disabled={!hasSources}
onClick={() => setPickerOpen(true)}
title={hasSources ? t('Variable aus vorherigem Node einfügen') : t('Keine vorherigen Nodes verfügbar')}
>
{t('Variable einfügen…')}
</button>
{!hasSources && (
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Keine vorherigen Nodes verfügbar')}</span>
)}
</div>
<textarea
ref={textareaRef}
value={strVal}
onChange={(e) => onChange(e.target.value)}
placeholder={param.name}
rows={6}
spellCheck={false}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: 4,
border: '1px solid #ccc',
resize: 'vertical',
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
minHeight: 120,
}}
/>
{tokenLegend.length > 0 && (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-secondary)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{t('Eingebundene Variablen')}</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{tokenLegend.map((row) => (
<li key={row.raw} style={{ marginBottom: 2 }}>
<code style={{ fontSize: 10 }}>{row.raw}</code>
{' — '}
{row.label}
</li>
))}
</ul>
</div>
)}
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={handlePick}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</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

@ -6,6 +6,8 @@
import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
export interface FieldRendererProps {
param: NodeTypeParameter;
@ -26,6 +28,16 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils';
import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config';
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}>
@ -152,8 +164,11 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]);
const autoSingleRef = React.useRef(false);
const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
React.useEffect(() => {
if (!instanceId || !request) return;
@ -170,11 +185,72 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
setLoadError(err instanceof Error ? err.message : String(err));
});
}, [instanceId, request, authority]);
React.useEffect(() => {
if (!instanceId || !request || !dataFlow?.currentNodeId) {
setUpstreamBindOptions([]);
return;
}
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
.then(({ paths }) => {
const opts = paths
.filter(
(p) =>
p.path.length > 0
&& (String(p.path[p.path.length - 1]) === 'id' || p.path.join('.').includes('connection')),
)
.map((p, i) => ({
key: `${p.producerNodeId}:${p.path.join('.')}:${i}`,
label: `${p.producerLabel ?? p.producerNodeId}${p.label}`,
ref: {
type: 'ref',
nodeId: p.producerNodeId,
path: p.path,
expectedType: p.type,
},
}));
setUpstreamBindOptions(opts);
})
.catch(() => setUpstreamBindOptions([]));
}, [instanceId, request, dataFlow?.currentNodeId, dataFlow?.nodes, dataFlow?.connections]);
React.useEffect(() => {
if (connections.length !== 1 || autoSingleRef.current) return;
if (value !== '' && value !== undefined && value !== null) return;
autoSingleRef.current = true;
onChange(connections[0].id);
}, [connections, value, onChange]);
const strVal = typeof value === 'string' ? value : '';
const isRef = typeof value === 'object' && value !== null && (value as { type?: string }).type === 'ref';
const selectedUpstreamKey =
isRef
? upstreamBindOptions.find((o) => {
const r = o.ref as { nodeId?: string; path?: unknown[] };
const v = value as { nodeId?: string; path?: unknown[] };
return r.nodeId === v.nodeId && JSON.stringify(r.path ?? []) === JSON.stringify(v.path ?? []);
})?.key ?? ''
: '';
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{connections.length === 0 && !loadError && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
{authority
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
</div>
)}
{connections.length === 1 && (
<div style={{ fontSize: 12, marginBottom: 4, color: '#444' }}>
{connections[0].label}
</div>
)}
{connections.length > 1 && (
<select
value={typeof value === 'string' ? value : ''}
value={strVal}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
>
@ -183,11 +259,24 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
{!loadError && connections.length === 0 && (
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
{authority
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
)}
{upstreamBindOptions.length > 0 && (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>{t('Oder aus vorherigem Node (DataRef)')}</div>
<select
value={selectedUpstreamKey}
onChange={(e) => {
const opt = upstreamBindOptions.find((o) => o.key === e.target.value);
if (opt) onChange(opt.ref);
else if (!e.target.value) onChange('');
}}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('—')}</option>
{upstreamBindOptions.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</div>
)}
{loadError && (
@ -449,6 +538,10 @@ const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = Array.isArray(value) ? value : [];
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]);
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
@ -457,28 +550,121 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff',
};
const selectStyle: React.CSSProperties = { ...inputStyle };
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>{param.description || param.name}</label>
{fields.map((f: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="date">{t('Datum')}</option>
<option value="checkbox">{t('Kontrollkästchen')}</option>
<option value="select">{t('Auswahl')}</option>
<option value="textarea">{t('Mehrzeilig')}</option>
<div key={i} style={{ background: '#f9f9f9', border: '1px solid #e0e0e0', borderRadius: 6, padding: '8px 10px', marginBottom: 6 }}>
{/* Row 1: Bezeichnung + delete */}
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'center' }}>
<input
type="text"
placeholder={t('Bezeichnung (Anzeigename)')}
value={String(f.label ?? '')}
onChange={(e) => updateField(i, 'label', e.target.value)}
style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
/>
<button
type="button"
onClick={() => removeField(i)}
title={t('Feld entfernen')}
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
>×</button>
</div>
{/* Row 2: Name + Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
<input
type="text"
placeholder="z.B. customerName"
value={String(f.name ?? '')}
onChange={(e) => updateField(i, 'name', e.target.value)}
style={inputStyle}
/>
</div>
<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}>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
<option value="group">{t('Gruppe')}</option>
</select>
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
</div>
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', paddingBottom: 5, whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} />
Pflicht
</label>
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
{String(f.type) === 'group' && (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => (
<div key={j} style={{ background: '#fff', border: '1px solid #e8e8e8', borderRadius: 4, padding: '6px 8px', marginBottom: 4 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="text"
placeholder={t('Name')}
value={String(sub.name ?? '')}
onChange={(e) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, name: e.target.value };
updateField(i, 'fields', nextFields);
}}
style={{ ...inputStyle, flex: 1 }}
/>
<select
value={String(sub.type ?? 'text')}
onChange={(e) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, type: e.target.value };
updateField(i, 'fields', nextFields);
}}
style={{ ...selectStyle, flex: 1 }}
>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
</select>
<button
type="button"
onClick={() => {
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
updateField(i, 'fields', nextFields);
}}
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
>×</button>
</div>
</div>
))}
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
<button
type="button"
onClick={() => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
updateField(i, 'fields', nextFields);
}}
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
>
+ {t('Unterfeld hinzufügen')}
</button>
</div>
)}
</div>
))}
<button
type="button"
onClick={addField}
style={{ width: '100%', padding: '6px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 12, background: '#fff', color: '#555' }}
>
+ {t('Feld hinzufügen')}
</button>
</div>
);
};
@ -601,6 +787,113 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
);
};
const ModelMultiSelect: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const [models, setModels] = React.useState<Array<{ displayName: string; connectorType?: string }>>([]);
const [loading, setLoading] = React.useState(false);
const [open, setOpen] = React.useState(false);
const selected: string[] = Array.isArray(value) ? value : [];
React.useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`${getApiBaseUrl()}/api/system/ai-models`, { credentials: 'include' })
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const items = (data?.models ?? []) as Array<{ displayName: string; connectorType?: string }>;
setModels(items);
})
.catch(() => { if (!cancelled) setModels([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const _toggle = (name: string) => {
const next = selected.includes(name)
? selected.filter((v) => v !== name)
: [...selected, name];
onChange(next);
};
const _removeTag = (name: string) => {
onChange(selected.filter((v) => v !== name));
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div
onClick={() => setOpen((o) => !o)}
style={{
width: '100%',
minHeight: 32,
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #ccc',
cursor: 'pointer',
display: 'flex',
flexWrap: 'wrap',
gap: 4,
alignItems: 'center',
background: '#fff',
}}
>
{selected.length === 0 && (
<span style={{ color: '#999', fontSize: 12 }}>{t('Alle erlaubten Modelle')}</span>
)}
{selected.map((name) => (
<span
key={name}
style={{
background: 'var(--primary-color, #2563eb)',
color: '#fff',
borderRadius: 3,
padding: '1px 6px',
fontSize: 11,
display: 'inline-flex',
alignItems: 'center',
gap: 3,
}}
>
{name}
<span
onClick={(e) => { e.stopPropagation(); _removeTag(name); }}
style={{ cursor: 'pointer', fontWeight: 700 }}
>
x
</span>
</span>
))}
</div>
{open && (
<div style={{ border: '1px solid #ddd', borderRadius: 4, marginTop: 4, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 4 }}>
{loading && <div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Lade Modelle...')}</div>}
{!loading && models.length === 0 && (
<div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
)}
{models.map((m) => (
<label
key={m.displayName}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 4px', fontSize: 12, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={selected.includes(m.displayName)}
onChange={() => _toggle(m.displayName)}
/>
<span>{m.displayName}</span>
{m.connectorType && (
<span style={{ fontSize: 10, color: '#888' }}>({m.connectorType})</span>
)}
</label>
))}
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
@ -608,6 +901,7 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
text: TextInput,
textarea: TextareaInput,
templateTextarea: TemplateTextareaRenderer,
number: NumberInput,
checkbox: CheckboxInput,
date: DateInput,
@ -615,12 +909,18 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
email: TextInput,
select: SelectInput,
multiselect: MultiSelectInput,
modelMultiSelect: ModelMultiSelect,
json: JsonEditor,
file: TextInput,
hidden: HiddenInput,
dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer,
contextAssignments: ContextAssignmentsEditor,
userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker,
clickupList: FolderPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,
@ -630,6 +930,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
condition: ConditionBuilder,
mappingTable: MappingTableEditor,
filterExpression: FilterExpressionEditor,
attachmentBuilder: JsonEditor,
};
export default FRONTEND_TYPE_RENDERERS;

View file

@ -0,0 +1,194 @@
// 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 → shown with warning (not removed).
expect(screen.getByText('documents')).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,14 +1,17 @@
/**
* 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.
* Includes a System Variables section.
*/
import React, { useState } from 'react';
import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef';
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
import type { DataPickOption, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import { findLoopAncestorIds } from './scopeHelpers';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -18,32 +21,156 @@ interface DataPickerProps {
onClose: () => void;
onPick: (ref: DataRef | SystemVarRef) => void;
availableSourceIds: string[];
nodes: Array<{ id: string; title?: string; type?: string }>;
nodes: Array<{ id: string; title?: string; type?: string; parameters?: Record<string, unknown> }>;
nodeOutputsPreview: Record<string, unknown>;
getNodeLabel: (node: { id: string; title?: string }) => string;
/** When set, the picker can hide incompatible candidates (strict toggle) and
* surfaces "Iterieren als Loop" affordances for List[X]X candidates. */
expectedParamType?: string;
}
interface PickablePath {
path: (string | number)[];
label: string;
type?: string;
/** True iff this path produces `List[X]` and the consumer expects `X`
* picking with iterate=true appends the wildcard segment. */
iterable?: boolean;
/** Annotated after strict-filter pass: type exists but doesn't match the expected param type. */
typeMismatch?: boolean;
/** Surfaced at the top of the list as the most common / recommended pick. */
recommended?: boolean;
/** Tooltip (Katalog oder Backend-Hinweistext). */
detail?: string;
}
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(
schema: PortSchema | undefined,
catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [],
baseSegments: string[] = [],
depth = 0,
): PickablePath[] {
if (!schema || !schema.fields) return [];
if (!schema || !schema.fields || depth > 8) return [];
const result: PickablePath[] = [];
for (const field of schema.fields) {
const fieldPath = [...basePath, field.name];
const label = fieldPath.map(String).join(' → ');
result.push({ path: fieldPath, label, type: field.type });
// For form schemas (kind=fromGraph), expose the whole `payload` object as a
// top-level pickable entry so the user can pass the entire form at once.
if (depth === 0 && schema.name?.startsWith('FormPayload')) {
result.push({
path: ['payload'],
label: 'Gesamtes Formular',
type: 'object',
recommended: true,
});
}
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
for (const field of schema.fields) {
const segHuman = _fieldSegHuman(field);
const fieldPath = [...basePath, field.name];
const label =
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 inner = m?.[1]?.trim();
if (inner && catalog[inner]) {
const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : '';
const itemBridge = pil || '*';
const nextSegments = [...baseSegments, segHuman, itemBridge];
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], nextSegments, depth + 1));
}
}
result.push({
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;
}
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
if (!expectedParamType) return paths;
return paths.map((p) => {
if (!p.type) return p;
const m = p.type.match(_LIST_INNER_RE);
if (m && m[1].trim() === expectedParamType) return { ...p, iterable: true };
return p;
});
}
function _deriveFormPortSchemaFromParams(
node: { parameters?: Record<string, unknown> },
paramKey: string,
formTypeToPort: Record<string, string> = {},
): PortSchema | undefined {
const resolvePortType = (rawType: string) => formTypeToPort[rawType] ?? rawType;
const raw = node.parameters?.[paramKey];
if (!Array.isArray(raw)) return undefined;
const fields: Array<{ name: string; type: string; description: string | Record<string, string>; required: boolean }> = [];
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue;
const rec = item as Record<string, unknown>;
if (typeof rec.name !== 'string') continue;
const lab = rec.label;
let description: string | Record<string, string> = rec.name;
if (typeof lab === 'string') description = lab;
else if (lab && typeof lab === 'object') description = lab as Record<string, string>;
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
if (rawType === 'group' && Array.isArray(rec.fields)) {
for (const sub of rec.fields as Record<string, unknown>[]) {
if (!sub || typeof sub.name !== 'string') continue;
const sl = sub.label;
let sdesc: string | Record<string, string> = `${rec.name}.${sub.name}`;
if (typeof sl === 'string') sdesc = sl;
else if (sl && typeof sl === 'object') sdesc = sl as Record<string, string>;
fields.push({
name: `${rec.name}.${sub.name}`,
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
description: sdesc,
required: Boolean(sub.required),
});
}
continue;
}
fields.push({
name: rec.name,
type: resolvePortType(rawType),
description,
required: Boolean(rec.required),
});
}
return fields.length ? { name: 'FormPayload_dynamic', fields } : undefined;
}
function _buildPathsFromPreview(
obj: unknown,
@ -72,13 +199,26 @@ function _buildPathsFromPreview(
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(
nodeId: string,
nodes: Array<{ id: string; type?: string }>,
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
nodeTypes: NodeType[],
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
catalog: Record<string, PortSchema>,
visited: Set<string> = new Set(),
formTypeToPort: Record<string, string> = {},
): PortSchema | undefined {
if (visited.has(nodeId)) return undefined;
visited.add(nodeId);
@ -88,17 +228,29 @@ function _resolveSchemaForNode(
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
if (!typeDef?.outputPorts) return undefined;
const port0 = typeDef.outputPorts[0];
const port0 = typeDef.outputPorts[0] as {
schema?: string | GraphDefinedSchemaRef;
dynamic?: boolean;
deriveFrom?: string;
};
if (!port0) return undefined;
if (port0.schema !== 'Transit') {
return catalog[port0.schema];
const schemaSpec = port0.schema;
if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') {
const paramKey = schemaSpec.parameter ?? 'fields';
return _deriveFormPortSchemaFromParams(node, paramKey, formTypeToPort);
}
if (port0.dynamic && port0.deriveFrom) {
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom, formTypeToPort);
}
if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') {
return catalog[schemaSpec];
}
// Transit: follow the incoming connection to find the real producer
const incoming = connections.find((c) => c.target === nodeId);
if (!incoming) return undefined;
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited);
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited, formTypeToPort);
}
export const DataPicker: React.FC<DataPickerProps> = ({ open,
@ -108,23 +260,45 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
nodes,
nodeOutputsPreview,
getNodeLabel,
expectedParamType,
}) => {
const { t } = useLanguage();
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [showSystem, setShowSystem] = useState(false);
// Default: when the consumer declares an expected type, show only compatible
// candidates ("strict" mode). User can override per-session via the toggle.
const [strictFilter, setStrictFilter] = useState<boolean>(Boolean(expectedParamType));
const ctx = useAutomation2DataFlow();
// NOTE: All hooks must be called unconditionally on every render to satisfy
// the Rules of Hooks. The `if (!open) return null;` early-return therefore
// has to live BELOW every hook in this component. Adding a useMemo (or any
// other hook) below it would change the hook count when the picker toggles
// open/closed and crash the whole tree (white screen).
const connectionsRaw = ctx?.connections ?? [];
const connections = useMemo(
() =>
connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle,
})),
[connectionsRaw],
);
const loopAncestorIds = useMemo(() => {
const cid = ctx?.currentNodeId;
if (!cid) return [] as string[];
return findLoopAncestorIds(nodes, connections, cid);
}, [ctx?.currentNodeId, nodes, connections]);
if (!open) return null;
const catalog = ctx?.portTypeCatalog ?? {};
const systemVars = ctx?.systemVariables ?? {};
const nodeTypes = ctx?.nodeTypes ?? [];
const connectionsRaw = ctx?.connections ?? [];
const connections = connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle,
}));
const formTypeToPort: Record<string, string> = Object.fromEntries(
(ctx?.formFieldTypes ?? []).map((f) => [f.id, f.portType])
);
const toggleExpand = (nodeId: string) => {
setExpandedNodes((prev) => {
@ -135,8 +309,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
});
};
const handlePick = (nodeId: string, path: (string | number)[]) => {
onPick(createRef(nodeId, path));
const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => {
onPick(createRef(nodeId, path, expectedType));
onClose();
};
/** Loop-Vorschlag: for List[X]X candidates, append the '*' wildcard so the
* engine maps the consumer over each element (executionEngine wildcard). */
const handlePickIterate = (nodeId: string, path: (string | number)[], expectedType?: string) => {
onPick(createRef(nodeId, [...path, '*'], expectedType));
onClose();
};
@ -145,17 +326,106 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClose();
};
return (
<div className={styles.dataPickerOverlay} onClick={onClose}>
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
const _dialog = (
<div
className={styles.dataPickerOverlay}
onClick={onClose}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
role="presentation"
>
<div
className={styles.dataPickerModal}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="automation2DataPickerTitle"
>
<div className={styles.dataPickerHeader}>
<h4 className={styles.dataPickerTitle}>{t('Datenquelle wählen')}</h4>
<h4 className={styles.dataPickerTitle} id="automation2DataPickerTitle">
{t('Datenquelle wählen')}
{expectedParamType && (
<span
className={styles.dataPickerTypeBadge}
title={t('Erwarteter Typ')}
>
{expectedParamType}
</span>
)}
</h4>
<div className={styles.dataPickerHeaderControls}>
{expectedParamType && (
<label className={styles.dataPickerStrictLabel}>
<input
type="checkbox"
checked={strictFilter}
onChange={(e) => setStrictFilter(e.target.checked)}
/>
{t('Nur kompatible')}
</label>
)}
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
×
</button>
</div>
</div>
<div className={styles.dataPickerBody}>
{/* System Variables Section */}
{loopAncestorIds.length > 0 && (
<div className={styles.dataPickerNodeSection}>
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
</div>
<div className={styles.dataPickerTree}>
{loopAncestorIds.map((loopId) => {
const loopNode = nodes.find((n) => n.id === loopId);
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
const loopSchema = catalog.LoopItem;
const loopPaths = loopSchema
? _buildPathsFromSchema(loopSchema, catalog, [], [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
: [
{ path: ['currentItem'], label: 'currentItem', type: 'Any' },
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' },
{ path: ['count'], label: 'count', type: 'int' },
];
return (
<div key={loopId} style={{ marginBottom: 6 }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div>
{loopPaths.map((p, i) => {
const mismatch =
Boolean(expectedParamType) &&
Boolean(p.type) &&
isCompatible(p.type!, expectedParamType!) === 'mismatch';
return (
<button
key={`${loopId}-${p.path.join('.')}-${i}`}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(loopId, p.path, p.type)}
>
{p.label}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{mismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
</div>
)}
{Object.keys(systemVars).length > 0 && (
<div className={styles.dataPickerNodeSection}>
<button
@ -176,7 +446,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClick={() => handlePickSystemVar(key)}
title={info.description}
>
{key} <span style={{ color: '#888', fontSize: 10 }}>({info.type})</span>
{key} <span className={styles.dataPickerLeafType}>({info.type})</span>
</button>
))}
</div>
@ -195,16 +465,53 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
}
return filteredIds.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
const label = node ? getNodeLabel(node) : nodeId;
// User-defined step title (or node-type label as fallback)
const stepTitle = node ? getNodeLabel(node) : nodeId;
const nodeTypeDef = node?.type ? nodeTypes.find((nt) => nt.id === node.type) : undefined;
// Human-readable type label (e.g. "Formular", "Web-Recherche")
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId);
const port0Def = nodeTypeDef?.outputPorts?.[0];
const backendPick =
port0Def?.dataPickOptions &&
Array.isArray(port0Def.dataPickOptions) &&
port0Def.dataPickOptions.length > 0;
let schemaPaths: PickablePath[];
if (backendPick) {
schemaPaths = _pathsFromDataPickOptions(port0Def!.dataPickOptions!);
} else {
const resolvedSchema = _resolveSchemaForNode(
nodeId, nodes, nodeTypes, connections, catalog,
nodeId,
nodes,
nodeTypes,
connections,
catalog,
new Set(),
formTypeToPort,
);
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
const paths = schemaPaths.length > 0
schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
}
const annotated = _markIterableCandidates(
schemaPaths.length > 0
? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType,
);
const markedPaths = annotated.map((p) => ({
...p,
typeMismatch:
strictFilter &&
Boolean(expectedParamType) &&
Boolean(p.type) &&
!p.iterable &&
isCompatible(p.type!, expectedParamType!) === 'mismatch',
}));
const orderedPaths = [
...markedPaths.filter((p) => p.recommended),
...markedPaths.filter((p) => !p.recommended),
];
return (
<div key={nodeId} className={styles.dataPickerNodeSection}>
@ -214,29 +521,63 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClick={() => toggleExpand(nodeId)}
>
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{label}</span>
{resolvedSchema && (
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
({resolvedSchema.name})
<span className={styles.dataPickerNodeLabel}>{stepTitle}</span>
{typeLabel && (
<span className={styles.dataPickerNodeSchemaHint}>
{typeLabel}
</span>
)}
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{paths.map((p, i) => (
<button
{orderedPaths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
{t('(keine Felder verfügbar)')}
</div>
)}
{orderedPaths.map((p, i) => (
<div
key={`${p.path.join('.')}-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<button
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(nodeId, p.path)}
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 style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
<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
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>
)}
@ -248,4 +589,6 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</div>
</div>
);
return createPortal(_dialog, document.body);
};

View file

@ -91,6 +91,37 @@ function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
return out;
}
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
{ path: ['currentItem'], pathLabel: 'currentItem' },
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
{ path: ['count'], pathLabel: 'count' },
];
if (preview && typeof preview === 'object') {
const ci = (preview as Record<string, unknown>).currentItem;
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
paths.push(...buildPickablePaths(v, ['currentItem', k]));
}
}
}
return paths;
}
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths = buildPickablePaths(preview);
if (preview && typeof preview === 'object') {
const rd = (preview as Record<string, unknown>).responseData;
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
for (const k of Object.keys(rd as Record<string, unknown>)) {
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
}
}
}
return paths;
}
export function pickPathsForNode(
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
preview: unknown,
@ -113,6 +144,12 @@ export function pickPathsForNode(
if (nt.startsWith('clickup.')) {
return buildClickUpOutputPaths(preview);
}
if (nt === 'flow.loop') {
return buildLoopCurrentItemPaths(preview);
}
if (nt === 'ai.prompt') {
return buildAiPromptPaths(preview);
}
return buildPickablePaths(preview);
}
@ -358,8 +395,6 @@ function getFormFieldType(
if (rawFieldType === 'email') return 'email';
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
if (rawFieldType === 'clickup_tasks') return 'string';
if (rawFieldType === 'clickup_status') return 'string';
return 'string';
}

View file

@ -0,0 +1,243 @@
// 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

@ -0,0 +1,282 @@
/**
* RequiredAttributePicker Phase-4 Schicht-4 binding affordance for
* required parameters of a Schicht-3 Adapter (Editor-Node).
*
* 0/1/N logic, applied on the set of typed source candidates:
* - 0 candidates red pill: "Keine typkompatible Quelle vorhanden"
* (user must add an upstream node first)
* - 1 candidate auto-bound chip with a "Andere wählen…" override button
* (still shown explicitly so the user sees what was chosen)
* - N candidates "Quelle wählen…" button that opens the DataPicker
* pre-filtered to the expected type
*
* The picker also surfaces a "Iterieren als Loop" hint when the expected type
* is `X` and an upstream candidate is `List[X]` see paramValidation.ts.
*/
import React, { useMemo, useState } from 'react';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from './DataPicker';
import { createRef, formatRefLabel, isRef, type DataRef, type SystemVarRef } from './dataRef';
import { findSourceCandidates, strictlyCompatible, type SourceCandidate } from './paramValidation';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface RequiredAttributePickerProps {
/** Display label for the parameter (already localized). */
label: string;
/** Type expected by the bound action argument (e.g. "DocumentList", "str"). */
expectedType?: string;
/** Current bound value (DataRef, SystemVarRef, or unset). */
value: unknown;
/** Persist a new binding (or `null` to clear). */
onChange: (next: DataRef | SystemVarRef | null) => void;
/** Optional description shown beneath the picker. */
description?: React.ReactNode;
}
export const RequiredAttributePicker: React.FC<RequiredAttributePickerProps> = ({
label,
expectedType,
value,
onChange,
description,
}) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = useState(false);
const consumerNodeId = ctx?.currentNodeId ?? '';
const nodes = ctx?.nodes ?? [];
const connections = ctx?.connections ?? [];
const nodeTypes = ctx?.nodeTypes ?? [];
const catalog = ctx?.portTypeCatalog ?? {};
const allCandidates: SourceCandidate[] = useMemo(() => {
if (!consumerNodeId) return [];
return findSourceCandidates({
consumerNodeId,
expectedType,
nodes,
connections: connections.map((c) => ({
id: c.id,
sourceId: c.sourceId,
sourceHandle: c.sourceHandle,
targetId: c.targetId,
targetHandle: c.targetHandle,
})),
nodeTypes,
portTypeCatalog: catalog,
});
}, [consumerNodeId, expectedType, nodes, connections, nodeTypes, catalog]);
const compatibleCandidates = useMemo(() => strictlyCompatible(allCandidates), [allCandidates]);
const isBoundRef = isRef(value);
const boundLabel = isBoundRef ? formatRefLabel(value as DataRef, nodes) : null;
// 0/1/N
const candidateCount = compatibleCandidates.length;
const single = candidateCount === 1 ? compatibleCandidates[0] : null;
const handleAutoBind = () => {
if (!single) return;
const ref = createRef(single.nodeId, single.iterable && expectedType ? [...single.path, '*'] : single.path, expectedType);
onChange(ref);
};
const handlePicked = (picked: DataRef | SystemVarRef) => {
onChange(picked);
};
return (
<div
className={styles.requiredAttributePicker}
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 0,
maxWidth: '100%',
}}
>
{/* Header: label always takes the full row (flex-basis 100 %), badge
wraps below prevents long type names like List[ActionDocument]
from escaping the panel frame on the right. */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
<label
style={{
fontSize: 12,
fontWeight: 600,
flex: '1 1 100%',
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{label}
<span style={{ color: 'var(--danger-color, #dc3545)', marginLeft: 2 }}>*</span>
</label>
{expectedType && (
<span
title={t('Erwarteter Typ')}
style={{
fontSize: 10,
fontFamily: 'monospace',
color: 'var(--text-secondary, #555)',
background: 'var(--bg-secondary, #eee)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{expectedType}
</span>
)}
</div>
{isBoundRef ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<span
title={typeof boundLabel === 'string' ? boundLabel : undefined}
style={{
padding: '2px 8px',
borderRadius: 12,
background: 'rgba(40,167,69,0.15)',
color: 'var(--success-color, #28a745)',
fontSize: 12,
fontWeight: 500,
maxWidth: '100%',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
>
{boundLabel}
</span>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
>
{t('Andere wählen…')}
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => onChange(null)}
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
title={t('Bindung entfernen')}
>
×
</button>
</div>
) : candidateCount === 0 ? (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 6,
padding: '4px 8px',
background: 'rgba(220,53,69,0.12)',
color: 'var(--danger-color, #dc3545)',
borderRadius: 6,
fontSize: 12,
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
<span aria-hidden="true" style={{ flexShrink: 0 }}></span>
<span style={{ minWidth: 0 }}>
{t('Keine typkompatible Quelle vorhanden — füge zuerst einen Knoten ein, der ')}
<code style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>{expectedType ?? '?'}</code>
{t(' liefert.')}
</span>
</div>
) : single ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
className={styles.retryButton}
onClick={handleAutoBind}
style={{
fontSize: 11,
padding: '3px 10px',
maxWidth: '100%',
whiteSpace: 'normal',
textAlign: 'left',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
title={t('Einzige passende Quelle übernehmen')}
>
{t('Vorschlag übernehmen:')}{' '}
<strong>
{nodes.find((n) => n.id === single.nodeId)?.title ?? single.nodeId}
{single.path.length > 0 ? ' → ' + single.path.map(String).join(' → ') : ''}
{single.iterable ? ' [' + t('iterieren') + ']' : ''}
</strong>
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '3px 10px', flexShrink: 0 }}
>
{t('Andere…')}
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '3px 10px', maxWidth: '100%' }}
>
{t('Quelle wählen…')} <span style={{ opacity: 0.6 }}>({candidateCount})</span>
</button>
</div>
)}
{description && (
<div
style={{
fontSize: 11,
color: 'var(--text-tertiary, #888)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{description}
</div>
)}
{pickerOpen && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={(picked) => {
handlePicked(picked);
setPickerOpen(false);
}}
availableSourceIds={ctx?.getAvailableSourceIds() ?? []}
nodes={nodes}
nodeOutputsPreview={ctx?.nodeOutputsPreview ?? {}}
getNodeLabel={(n) =>
ctx?.getNodeLabel(n as { id: string; title?: string; label?: string; type?: string }) ?? n.id
}
expectedParamType={expectedType}
/>
)}
</div>
);
};

View file

@ -1,294 +0,0 @@
/**
* Sync input.form / trigger.form fields + ClickUp "Aufgabe erstellen" refs from a selected ClickUp list.
*/
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { FormField } from './types';
import { createRef } from './dataRef';
export type ClickUpFieldLike = Record<string, unknown>;
function buildReverseAdjacency(connections: CanvasConnection[]): Record<string, string[]> {
const rev: Record<string, string[]> = {};
for (const c of connections) {
if (!rev[c.targetId]) rev[c.targetId] = [];
rev[c.targetId].push(c.sourceId);
}
return rev;
}
/** Nearest form node upstream (toward triggers) of the ClickUp node. */
export function findClosestUpstreamFormNode(
targetNodeId: string,
nodes: CanvasNode[],
connections: CanvasConnection[]
): CanvasNode | null {
const nodeById = new Map(nodes.map((n) => [n.id, n]));
const rev = buildReverseAdjacency(connections);
const queue: string[] = [...(rev[targetNodeId] ?? [])];
const visited = new Set<string>();
while (queue.length > 0) {
const nid = queue.shift()!;
if (visited.has(nid)) continue;
visited.add(nid);
const n = nodeById.get(nid);
if (!n) continue;
if (n.type === 'input.form' || n.type === 'trigger.form') return n;
for (const p of rev[nid] ?? []) {
if (!visited.has(p)) queue.push(p);
}
}
return null;
}
export function normalizeClickUpFieldType(raw: unknown): string {
return String(raw ?? 'short_text')
.trim()
.toLowerCase()
.replace(/-/g, '_')
.replace(/\s+/g, '_');
}
function linkedListIdFromRelationshipField(field: ClickUpFieldLike): string | null {
const tc = (field.type_config ?? {}) as Record<string, unknown>;
const asId = (v: unknown): string | null => {
if (typeof v === 'string' && v.trim()) return v.trim();
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
return null;
};
const keys = [
'linked_list_id',
'list_id',
'related_list_id',
'relationship_list_id',
'resource_id',
];
for (const k of keys) {
const raw = tc[k];
const id = asId(raw);
if (id) return id;
if (raw && typeof raw === 'object' && raw !== null) {
const nested = asId((raw as Record<string, unknown>).id);
if (nested) return nested;
}
}
const rel = tc.relationship;
if (rel && typeof rel === 'object' && rel !== null) {
const r = rel as Record<string, unknown>;
const fromRel = asId(r.list_id ?? r.id ?? r.target_id ?? r.linked_list_id ?? r.resource_id);
if (fromRel) return fromRel;
}
return null;
}
function fieldUnsupported(ft: string): boolean {
return ['tasks', 'user', 'users'].includes(ft);
}
function mapCuToInputFormField(
field: ClickUpFieldLike,
connectionId: string,
parentListId: string
): FormField | null {
const fid = String(field.id ?? '');
if (!fid) return null;
const fname = String(field.name ?? fid);
const ft = normalizeClickUpFieldType(field.type);
if (fieldUnsupported(ft)) return null;
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
const label = fname || name;
if (ft === 'list_relationship') {
const lid = linkedListIdFromRelationshipField(field) ?? parentListId;
return {
name,
label,
type: 'clickup_tasks',
required: false,
clickupConnectionId: connectionId,
clickupListId: lid,
};
}
if (
ft === 'drop_down' ||
ft === 'dropdown' ||
ft === 'text' ||
ft === 'long_text' ||
ft === 'short_text' ||
ft === 'email' ||
ft === 'phone' ||
ft === 'url'
) {
return { name, label, type: 'string', required: false };
}
if (ft === 'number' || ft === 'currency') {
return { name, label, type: 'number', required: false };
}
if (ft === 'date') {
return { name, label, type: 'date', required: false };
}
if (ft === 'checkbox') {
return { name, label, type: 'boolean', required: false };
}
return { name, label, type: 'string', required: false };
}
/** trigger.form row; `clickup_status` carries options from the same list API as the ClickUp node dropdown. */
export type TriggerFormFieldRow = {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
statusOptions?: Array<{ value: string; label: string }>;
};
function mapCuToTriggerFormField(field: ClickUpFieldLike, _connectionId: string, _parentListId: string): TriggerFormFieldRow | null {
const fid = String(field.id ?? '');
if (!fid) return null;
const fname = String(field.name ?? fid);
const ft = normalizeClickUpFieldType(field.type);
if (fieldUnsupported(ft)) return null;
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
const label = fname || name;
if (ft === 'list_relationship') {
return { name, label, type: 'text' };
}
if (ft === 'number' || ft === 'currency') {
return { name, label, type: 'number' };
}
if (ft === 'date') {
return { name, label, type: 'date' };
}
if (ft === 'checkbox') {
return { name, label, type: 'boolean' };
}
if (ft === 'email') {
return { name, label, type: 'email' };
}
return { name, label, type: 'text' };
}
export const PAYLOAD_TITLE = 'title';
export const PAYLOAD_DESCRIPTION = 'description';
export const PAYLOAD_STATUS = 'clickup_status';
export const PAYLOAD_PRIORITY = 'clickup_priority';
export const PAYLOAD_DUE = 'clickup_due_date';
export const PAYLOAD_TIME_H = 'clickup_time_estimate_h';
/** Same ordering as ClickUp list `statuses` (GET /list/{id}). */
export function statusOptionsFromListStatuses(
listStatuses: Array<{ status: string; orderindex: number }>
): Array<{ value: string; label: string }> {
return [...listStatuses]
.sort((a, b) => a.orderindex - b.orderindex)
.map((s) => ({ value: s.status, label: s.status }));
}
export interface SyncFromListResult {
inputFormFields: FormField[];
triggerFormFields: TriggerFormFieldRow[];
clickupPatch: Record<string, unknown>;
}
/**
* Build form field rows + ClickUp createTask parameter patch (refs payload.*).
*/
export function buildSyncFromClickUpList(args: {
formNodeId: string;
listFields: ClickUpFieldLike[];
/** From GET /list/{id} → list.statuses (same source as the ClickUp node status dropdown). */
listStatuses: Array<{ status: string; orderindex: number }>;
connectionId: string;
teamId: string;
listId: string;
}): SyncFromListResult {
const { formNodeId, listFields, listStatuses, connectionId, teamId, listId } = args;
const ref = (key: string) => createRef(formNodeId, ['payload', key]);
const statusOpts = statusOptionsFromListStatuses(listStatuses);
const standardInput: FormField[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'string', required: true },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'string', required: false },
...(statusOpts.length > 0
? [
{
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
required: false,
clickupStatusOptions: statusOpts,
} as FormField,
]
: []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number', required: false },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
];
const statusTriggerRow: TriggerFormFieldRow | null =
statusOpts.length > 0
? {
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
statusOptions: statusOpts,
}
: null;
const standardTrigger: TriggerFormFieldRow[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
...(statusTriggerRow ? [statusTriggerRow] : []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number' },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
];
if (statusOpts.length > 0) {
standardTrigger.splice(2, 0, {
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
statusOptions: statusOpts,
});
}
const customInput: FormField[] = [];
const customTrigger: TriggerFormFieldRow[] = [];
const customRefs: Record<string, unknown> = {};
for (const f of listFields) {
if (!f || typeof f !== 'object') continue;
const inf = mapCuToInputFormField(f as ClickUpFieldLike, connectionId, listId);
const tr = mapCuToTriggerFormField(f as ClickUpFieldLike, connectionId, listId);
if (inf) customInput.push(inf);
if (tr) customTrigger.push(tr);
const fid = String((f as ClickUpFieldLike).id ?? '');
const payloadKey = inf?.name;
if (fid && payloadKey) {
customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]);
}
}
const inputFormFields = [...standardInput, ...customInput];
const triggerFormFields = [...standardTrigger, ...customTrigger];
const clickupPatch: Record<string, unknown> = {
connectionId,
teamId,
listId,
path: `/team/${teamId}/list/${listId}`,
name: ref(PAYLOAD_TITLE),
description: ref(PAYLOAD_DESCRIPTION),
taskPriority: ref(PAYLOAD_PRIORITY),
taskDueDateMs: ref(PAYLOAD_DUE),
taskTimeEstimateHours: ref(PAYLOAD_TIME_H),
};
if (statusOpts.length > 0) {
clickupPatch.taskStatus = ref(PAYLOAD_STATUS);
}
if (Object.keys(customRefs).length) {
clickupPatch.customFieldValues = customRefs;
}
return { inputFormFields, triggerFormFields, clickupPatch };
}

View file

@ -8,6 +8,8 @@ export interface DataRef {
type: 'ref';
nodeId: string;
path: (string | number)[];
/** Optional declared type at bind time (for UI / validation hints) */
expectedType?: string;
}
/** Explicit static value wrapper */
@ -63,8 +65,31 @@ export function createSystemVar(variable: string): SystemVarRef {
}
/** Create a reference object */
export function createRef(nodeId: string, path: (string | number)[] = []): DataRef {
return { type: 'ref', nodeId, path };
export function createRef(nodeId: string, path: (string | number)[] = [], expectedType?: string): DataRef {
return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) };
}
/**
* Structural type compatibility using the canonical type vocabulary: str / int / float / bool / Any.
* All node parameters and form field schemas must use these types (no `string`, `number`, `boolean`
* aliases) so no alias-mapping is needed here.
*
* `Any` as expected type accepts everything.
* `Any`, `object`, or `dict` as produced type coerces to `str` (backend serializes via json.dumps).
*/
export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' {
if (!expectedType || !producedType) return 'ok';
if (producedType === expectedType) return 'ok';
// Any-expected: accept all sources
if (expectedType === 'Any') return 'ok';
// Any-produced: compatible with everything (coerce where needed)
if (producedType === 'Any') return 'coerce';
// Numeric coercion
if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce';
if (expectedType === 'int' && producedType === 'str') return 'coerce';
// Object/dict → str: backend serializes to JSON text
if (expectedType === 'str' && (producedType === 'object' || producedType === 'dict')) return 'coerce';
return 'mismatch';
}
/** Create a value wrapper */

View file

@ -8,6 +8,7 @@ import type {
Automation2Graph,
Automation2GraphNode,
Automation2Connection,
GraphDefinedSchemaRef,
} from '../../../../api/workflowApi';
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
@ -42,7 +43,10 @@ export function fromApiGraph(
? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts }))
: undefined,
outputPorts: nt?.outputPorts
? Object.entries(nt.outputPorts).map(([, v]) => ({ name: '', schema: (v as { schema?: string }).schema ?? '' }))
? Object.entries(nt.outputPorts).map(([, v]) => ({
name: '',
schema: (v as { schema?: string | GraphDefinedSchemaRef }).schema ?? '',
}))
: undefined,
};
});

View file

@ -69,9 +69,39 @@ export function buildNodeOutputPreview(
return { _transit: true, _meta: {}, data: {} };
}
if (typeof port0.schema !== 'string') {
return {};
}
return _buildSchemaPreview(port0.schema);
}
function _buildEmailItemPreview(): Record<string, unknown> {
return {
from: { emailAddress: { address: 'sender@example.com', name: 'Sender' } },
subject: '...',
body: { contentType: 'HTML', content: '...' },
receivedDateTime: '2026-01-01T00:00:00Z',
toRecipients: [],
hasAttachments: false,
id: '...',
};
}
function _buildAiResponseDataPreview(params: Record<string, unknown>): Record<string, unknown> | null {
if (params.resultType !== 'json') return null;
const prompt = String(params.aiPrompt || params.prompt || '');
if (!prompt) return null;
const fields: Record<string, unknown> = {};
const re = /["']?(\w+)["']?\s*:\s*(?:true|false|"[^"]*"|'[^']*'|\d+|boolean|string|number|bool)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(prompt)) !== null) {
const f = m[1];
if (f && !['type', 'value', 'key'].includes(f)) fields[f] = '...';
}
return Object.keys(fields).length > 0 ? fields : null;
}
/** Build full nodeOutputsPreview map from graph */
export function buildNodeOutputsPreview(
nodes: CanvasNode[],
@ -88,5 +118,32 @@ export function buildNodeOutputsPreview(
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
}
}
for (const n of nodes) {
if (n.id in (nodeOutputsFromRun ?? {})) continue;
if (n.type === 'flow.loop') {
const items = n.parameters?.items;
if (items && typeof items === 'object' && (items as { type?: string }).type === 'ref') {
const ref = items as { nodeId: string; path?: (string | number)[] };
const sourceNode = nodes.find((sn) => sn.id === ref.nodeId);
const sourceDef = sourceNode ? typeMap.get(sourceNode.type) : undefined;
const sourceSchema = sourceDef?.outputPorts?.[0]?.schema;
if (sourceSchema === 'EmailList') {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, currentItem: _buildEmailItemPreview() };
}
}
}
if (n.type === 'ai.prompt' && n.parameters) {
const rdPreview = _buildAiResponseDataPreview(n.parameters);
if (rdPreview) {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, responseData: rdPreview };
}
}
}
return result;
}

View file

@ -0,0 +1,318 @@
// 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

@ -0,0 +1,216 @@
/**
* Phase-4 Schicht-4 (Instanz-Bindings) Validation utilities.
*
* Single source of truth for two questions every UI surface needs to answer:
* 1. "Is this required parameter on this node bound to anything?"
* 2. "Which upstream nodes are type-compatible sources for this parameter?"
*
* Used by:
* - RequiredAttributePicker (renders 0/1/N affordance based on candidate count)
* - NodeConfigPanel (orders required params first, surfaces missing-source pill)
* - FlowCanvas (red error badge per node when any required param is unbound)
* - CanvasHeader (Run button disabled when any node has unbound required params)
*
* The required check is deliberately conservative: a param counts as "bound"
* if it has any non-empty value, a non-empty static value-wrapper, a ref, or a
* system-var ref. Empty string / null / undefined / { type: 'value', value: '' }
* all count as unbound.
*/
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, OutputPortDef, PortSchema } from '../../../../api/workflowApi';
import { isCompatible, isRef, isSystemVar, isValue } from './dataRef';
import { getAvailableSources } from './dataFlowGraph';
const _LIST_INNER_RE = /^List\[(.+)\]$/;
/** A candidate path on an upstream node that could satisfy a parameter binding. */
export interface SourceCandidate {
nodeId: string;
/** JSON path on the node output, e.g. ['documents', 0, 'name']. */
path: (string | number)[];
/** Type as declared by the schema field at this path (best-effort). */
type?: string;
/** Compatibility verdict against the requested type. */
compat: 'ok' | 'coerce' | 'mismatch';
/** True iff the candidate is a List that, by element-iteration ('*'), would
* satisfy the requested scalar type the "iterieren als Loop-Vorschlag". */
iterable?: boolean;
}
/** Decide whether a parameter value counts as "bound" for required-check purposes. */
export function isParamBound(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.length > 0;
if (typeof value === 'number' || typeof value === 'boolean') return true;
if (isRef(value)) return Boolean(value.nodeId);
if (isSystemVar(value)) return Boolean(value.variable);
if (isValue(value)) {
const inner = value.value;
if (inner === null || inner === undefined) return false;
if (typeof inner === 'string') return inner.length > 0;
if (Array.isArray(inner)) return inner.length > 0;
return true;
}
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'object') return Object.keys(value as object).length > 0;
return false;
}
/** A "required" param on a node that has no value and no incoming binding. */
export interface RequiredParamError {
paramName: string;
paramLabel: string;
paramType?: string;
}
/** Walk a node's parameter spec + values and flag every required-but-unbound.
*
* Safety net: params with `frontendType: 'hidden'` are excluded they have
* no UI surface (the panel skips them entirely), so reporting them as
* "Pflichtfeld ohne Quelle" would create a phantom error the user cannot
* resolve. Hidden-required params should be auto-set by the adapter or
* caught in tests, never surfaced to end users.
*/
export function findRequiredErrors(
node: CanvasNode,
nodeType: NodeType | undefined,
resolveLabel: (param: NodeTypeParameter) => string = (p) => p.name,
): RequiredParamError[] {
if (!nodeType) return [];
const errors: RequiredParamError[] = [];
const values = node.parameters ?? {};
for (const param of nodeType.parameters ?? []) {
if (!param.required) continue;
if (param.frontendType === 'hidden') continue;
if (isParamBound(values[param.name])) continue;
errors.push({ paramName: param.name, paramLabel: resolveLabel(param), paramType: param.type });
}
return errors;
}
/** Map of nodeId → required errors. Empty entries are omitted. */
export function findGraphErrors(
nodes: CanvasNode[],
nodeTypes: NodeType[],
resolveLabel?: (param: NodeTypeParameter) => string,
): Record<string, RequiredParamError[]> {
const byId: Record<string, RequiredParamError[]> = {};
const byTypeId = new Map(nodeTypes.map((nt) => [nt.id, nt]));
for (const n of nodes) {
const errs = findRequiredErrors(n, byTypeId.get(n.type), resolveLabel);
if (errs.length) byId[n.id] = errs;
}
return byId;
}
/** Resolve the schema produced by an output port (Transit follows incoming connection). */
function _resolveOutputSchemaName(
nodeId: string,
nodes: CanvasNode[],
connections: CanvasConnection[],
nodeTypes: NodeType[],
visited: Set<string> = new Set(),
): { schemaName?: string; node?: CanvasNode; portDef?: OutputPortDef } {
if (visited.has(nodeId)) return {};
visited.add(nodeId);
const node = nodes.find((n) => n.id === nodeId);
if (!node) return {};
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
const port0 = typeDef?.outputPorts?.[0] as OutputPortDef | undefined;
if (!port0) return { node };
const spec = port0.schema as string | GraphDefinedSchemaRef | undefined;
if (typeof spec === 'object' && spec !== null && spec.kind === 'fromGraph') {
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
}
if (port0.dynamic) {
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
}
if (typeof spec === 'string' && spec !== 'Transit') {
return { schemaName: spec, node, portDef: port0 };
}
// Transit: follow upstream
const incoming = connections.find((c) => c.targetId === nodeId);
if (!incoming) return { node };
return _resolveOutputSchemaName(incoming.sourceId, nodes, connections, nodeTypes, visited);
}
/** Build candidate paths from a schema, recursing into List-element schemas one level deep. */
function _candidatesFromSchema(
schema: PortSchema | undefined,
catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [],
depth = 0,
): Array<{ path: (string | number)[]; type?: string }> {
if (!schema || !schema.fields || depth > 6) return [];
const out: Array<{ path: (string | number)[]; type?: string }> = [];
for (const field of schema.fields) {
const fieldPath = [...basePath, field.name];
out.push({ path: fieldPath, type: field.type });
const inner = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE)?.[1]?.trim() : undefined;
if (inner && catalog[inner]) {
out.push(..._candidatesFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
}
}
return out;
}
/**
* Compute every typed source candidate that could satisfy `expectedType`
* for the given consumer node. Includes ranked compatibility per candidate
* and a `iterable` flag for List-XX "iterate as Loop" suggestions.
*
* If `expectedType` is omitted, returns all candidates (all marked 'ok').
*/
export function findSourceCandidates(args: {
consumerNodeId: string;
expectedType?: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
portTypeCatalog: Record<string, PortSchema>;
}): SourceCandidate[] {
const { consumerNodeId, expectedType, nodes, connections, nodeTypes, portTypeCatalog } = args;
const sourceIds = getAvailableSources(consumerNodeId, nodes, connections).filter((id) => {
const n = nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const results: SourceCandidate[] = [];
for (const nid of sourceIds) {
const { schemaName } = _resolveOutputSchemaName(nid, nodes, connections, nodeTypes);
const schema = schemaName ? portTypeCatalog[schemaName] : undefined;
const wholeType = schemaName ?? undefined;
results.push({
nodeId: nid,
path: [],
type: wholeType,
compat: expectedType && wholeType ? isCompatible(wholeType, expectedType) : 'ok',
iterable: _isIterableMatch(wholeType, expectedType),
});
for (const cand of _candidatesFromSchema(schema, portTypeCatalog)) {
const compat = expectedType && cand.type ? isCompatible(cand.type, expectedType) : 'ok';
results.push({
nodeId: nid,
path: cand.path,
type: cand.type,
compat,
iterable: _isIterableMatch(cand.type, expectedType),
});
}
}
return results;
}
/** True iff `producedType` is `List[X]` and `expectedType` equals `X`. */
function _isIterableMatch(producedType?: string, expectedType?: string): boolean {
if (!producedType || !expectedType) return false;
const m = producedType.match(_LIST_INNER_RE);
if (!m) return false;
return m[1].trim() === expectedType;
}
/** Filter candidates to only those that satisfy `expectedType` (strict mode). */
export function strictlyCompatible(candidates: SourceCandidate[]): SourceCandidate[] {
return candidates.filter((c) => c.compat === 'ok' || c.compat === 'coerce' || c.iterable === true);
}

View file

@ -0,0 +1,55 @@
/**
* 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

@ -3,17 +3,15 @@
*/
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import type { AttributeType } from '../../../../utils/attributeTypeMapper';
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
/** input.form / trigger.form field row. */
export type FormField = {
name?: string;
type?: string;
type?: AttributeType;
label?: string;
required?: boolean;
clickupConnectionId?: string;
clickupListId?: string;
/** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */
clickupStatusOptions?: Array<{ value: string; label: string }>;
options?: Array<{ value: string; label: string }>;
};
export interface NodeConfigRendererProps {

View file

@ -24,11 +24,110 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
/** Function type for resolving localized labels */
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
/** Build an HTML accept attribute from an upload node config's allowedTypes array. */
export function getAcceptStringFromConfig(
config: Record<string, unknown>
): string {
const types = config.allowedTypes;
if (!Array.isArray(types) || types.length === 0) return '*';
return types.join(',');
/** Extension → MIME when the browser leaves ``File.type`` empty (common on Windows). */
const _EXT_TO_MIME: Record<string, string> = {
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.zip': 'application/zip',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.jpe': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
function _extensionVariants(ext: string): string[] {
const e = ext.toLowerCase();
if (e === '.jpeg' || e === '.jpe') return ['.jpeg', '.jpe', '.jpg'];
if (e === '.jpg') return ['.jpg', '.jpeg', '.jpe'];
return [e];
}
/**
* True if ``file`` satisfies an HTML-style ``accept`` string (extensions, MIME types, ``image/*``).
* - ``*`` or empty allow all
* - Normalizes gateway multiselect tokens ``pdf`` ``.pdf`` (via {@link getAcceptStringFromConfig})
* - Infers MIME from extension when ``file.type`` is empty
*/
export function fileMatchesAccept(file: File, accept: string): boolean {
const trimmed = (accept ?? '').trim();
if (!trimmed || trimmed === '*') return true;
const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return true;
const name = file.name ?? '';
const ext =
name.includes('.') && !name.endsWith('.')
? '.' + (name.split('.').pop() ?? '').toLowerCase()
: '';
let mime = (file.type ?? '').trim().toLowerCase();
if (!mime && ext && _EXT_TO_MIME[ext]) {
mime = _EXT_TO_MIME[ext];
}
const extVariants = ext ? _extensionVariants(ext) : [];
for (const rawPart of parts) {
for (const p of rawPart.split(',').map((s) => s.trim()).filter(Boolean)) {
const pp = p.toLowerCase();
if (pp === '*') return true;
if (pp.startsWith('.')) {
if (extVariants.some((e) => e === pp)) return true;
continue;
}
if (pp.endsWith('/*')) {
const prefix = pp.slice(0, -2);
if (mime.startsWith(prefix + '/')) return true;
continue;
}
if (pp.includes('/')) {
if (mime === pp) return true;
continue;
}
// Bare token left from legacy configs, e.g. "pdf" without dot
if (/^[a-z0-9]{2,16}$/.test(pp)) {
const dotted = '.' + pp;
if (extVariants.includes(dotted)) return true;
if (extVariants.some((e) => _extensionVariants(e).includes(dotted))) return true;
}
}
}
return false;
}
/**
* Build a combined accept list from ``allowedTypes`` (multiselect: pdf, docx, ) and optional
* manual ``accept`` string on the node.
*/
export function getAcceptStringFromConfig(config: Record<string, unknown>): string {
const fromParam =
typeof config.accept === 'string' && config.accept.trim() ? config.accept.trim() : '';
const types = config.allowedTypes;
let fromAllowed = '';
if (Array.isArray(types) && types.length > 0) {
fromAllowed = types
.map((t) => {
const s = String(t).trim().toLowerCase();
if (!s) return '';
if (s === '*') return '*';
if (s.includes('/') || s.endsWith('/*')) return s;
if (s.startsWith('.')) return s;
return `.${s.replace(/^\.+/, '')}`;
})
.filter(Boolean)
.join(',');
}
if (fromParam && fromAllowed) return `${fromParam},${fromAllowed}`;
if (fromParam) return fromParam;
if (fromAllowed) return fromAllowed;
return '*';
}

View file

@ -4,40 +4,24 @@
import React, { useMemo } from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import type { FormField } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
type FormField = {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
statusOptions?: Array<{ value: string; label: string }>;
};
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
const raw = params.formFields;
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
return raw.map((f, i) => {
if (f && typeof f === 'object' && !Array.isArray(f)) {
const o = f as Record<string, unknown>;
const fieldType = String(o.type ?? 'text');
const rawType = String(o.type ?? 'text');
const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = (
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
) as FormField['type'];
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
return {
name,
label,
type: 'clickup_status',
statusOptions: o.statusOptions as Array<{ value: string; label: string }>,
};
}
return { name, label, type };
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
return { name, label, type } as FormField;
}
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
});
@ -45,6 +29,10 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = useMemo(() => _parseFields(params, t), [params, t]);
const setFields = (next: FormField[]) => {
@ -64,7 +52,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<input
className={styles.startsInput}
placeholder={t('Name (Payload-Key)')}
value={f.name}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, name: e.target.value };
@ -74,7 +62,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<input
className={styles.startsInput}
placeholder={t('Beschriftung')}
value={f.label}
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, label: e.target.value };
@ -83,24 +71,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
/>
<select
className={styles.startsSelect}
value={f.type}
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
const fieldType = e.target.value as FormField['type'];
if (fieldType === 'clickup_status') {
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
} else {
next[idx] = { name: f.name, label: f.label, type: fieldType };
}
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] };
setFields(next);
}}
>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="email">{t('E-Mail')}</option>
<option value="date">{t('Datum')}</option>
<option value="boolean">{t('Ja/Nein')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
</select>
<button
type="button"

View file

@ -1,187 +0,0 @@
.folderTree {
font-size: 0.875rem;
user-select: none;
}
.treeNode {
display: flex;
align-items: center;
padding: 2px 4px;
cursor: pointer;
border-radius: 4px;
gap: 2px;
min-height: 26px;
position: relative;
}
.treeNode:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.treeNode.selected {
background: var(--color-bg-selected, rgba(25, 118, 210, 0.08));
font-weight: 600;
}
.treeNode.multiSelected {
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14));
box-shadow: inset 3px 0 0 var(--color-primary, #F25843);
}
.treeNode.multiSelected:hover {
background: var(--color-bg-multi-selected-hover, rgba(25, 118, 210, 0.20));
}
.treeNode.dropTarget {
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15));
outline: 2px dashed var(--color-primary, #F25843);
outline-offset: -2px;
}
.treeNode.dragging {
opacity: 0.5;
}
.chevron {
width: 12px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s ease;
color: var(--color-text-secondary, #666);
font-size: 8px;
}
.chevron.expanded {
transform: rotate(90deg);
}
.chevron.empty {
visibility: hidden;
}
.folderIcon {
flex-shrink: 0;
color: var(--color-text-secondary, #888);
font-size: 13px;
}
.folderName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.renameInput {
flex: 1;
border: 1px solid var(--color-primary, #F25843);
border-radius: 3px;
padding: 1px 4px;
font-size: inherit;
font-family: inherit;
outline: none;
min-width: 0;
}
/* Right zone: contains dynamic on-hover actions + always-visible stable trio.
* The stable trio (chat / scope / neutralize) sits at the right edge in a
* fixed slot order so icons never jump. Dynamic actions appear on hover
* to the left of the trio without displacing it. */
.rightZone {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.actions {
display: none;
gap: 2px;
flex-shrink: 0;
}
.treeNode:hover .actions {
display: flex;
}
.stableActions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.iconSlot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
flex-shrink: 0;
}
.iconSlot.placeholder {
visibility: hidden;
}
.actionBtn {
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
color: var(--color-text-secondary, #888);
font-size: 12px;
line-height: 1;
display: flex;
align-items: center;
}
.actionBtn:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.08));
color: var(--color-text-primary, #333);
}
.actionBtn.danger:hover {
color: var(--color-error, #d32f2f);
}
.children {
padding-left: 10px;
}
.rootLabel {
font-weight: 600;
color: var(--color-text-primary, #333);
}
/* File nodes inside the tree */
.fileNode {
cursor: pointer;
}
.fileNode:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.fileIcon {
flex-shrink: 0;
font-size: 11px;
}
.fileSize {
font-size: 10px;
color: var(--color-text-secondary, #999);
flex-shrink: 0;
}
.rootActions {
display: flex;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}

View file

@ -1,915 +0,0 @@
/**
* FolderTree Shared recursive folder/file tree component.
*
* Used on the Files page and in the Workspace chat.
* Supports:
* - Alphabetical sorting per level (folders first, then files)
* - Multi-selection (CTRL+click, SHIFT+click) with visual highlight
* - Batch drag-and-drop for selected items
* - Inline CRUD icons for folders
* - showFiles mode renders files inline under their parent folder
* - Drag-out: sets application/tree-items on dataTransfer for external drop targets
*/
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaSyncAlt, FaDownload } from 'react-icons/fa';
import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
import styles from './FolderTree.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
/* ── Public types ──────────────────────────────────────────────────────── */
export interface FolderNode {
id: string;
name: string;
parentId: string | null;
fileCount?: number;
children?: FolderNode[];
isProtected?: boolean;
isReadonly?: boolean;
icon?: string;
neutralize?: boolean;
scope?: string;
}
export interface FileNode {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
scope?: string;
neutralize?: boolean;
sysCreatedBy?: string;
isReadonly?: boolean;
}
export interface TreeItem {
id: string;
type: 'file' | 'folder';
name: string;
}
export interface FolderTreeProps {
folders: FolderNode[];
files?: FileNode[];
showFiles?: boolean;
selectedFolderId: string | null;
onSelect: (folderId: string | null) => void;
onFileSelect?: (fileId: string) => void;
selectedItemIds?: Set<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
expandedIds?: Set<string>;
onToggleExpand?: (id: string) => void;
onRefresh?: () => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
function _buildTree(folders: FolderNode[]): FolderNode[] {
const map = new Map<string, FolderNode>();
const roots: FolderNode[] = [];
for (const f of folders) map.set(f.id, { ...f, children: [] });
for (const f of folders) {
const node = map.get(f.id)!;
if (f.parentId && map.has(f.parentId)) {
map.get(f.parentId)!.children!.push(node);
} else {
roots.push(node);
}
}
const _sortLevel = (nodes: FolderNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
for (const n of nodes) {
if (n.children && n.children.length > 0) _sortLevel(n.children);
}
};
_sortLevel(roots);
return roots;
}
function _groupFilesByFolder(files: FileNode[]): Map<string, FileNode[]> {
const map = new Map<string, FileNode[]>();
for (const f of files) {
const key = f.folderId || '';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(f);
}
for (const [, arr] of map) {
arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
}
return map;
}
function _computeFlatList(
tree: FolderNode[],
expandedIds: Set<string>,
showFiles: boolean,
filesByFolder: Map<string, FileNode[]>,
): TreeItem[] {
const result: TreeItem[] = [];
const _walk = (nodes: FolderNode[]) => {
for (const node of nodes) {
result.push({ id: node.id, type: 'folder', name: node.name });
if (expandedIds.has(node.id)) {
if (node.children) _walk(node.children);
if (showFiles) {
for (const f of (filesByFolder.get(node.id) || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
}
}
};
_walk(tree);
if (showFiles) {
for (const f of (filesByFolder.get('') || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
return result;
}
function _fileIcon(mime?: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
if (mime.startsWith('audio/')) return '\uD83C\uDFB5';
if (mime.startsWith('video/')) return '\uD83C\uDFA5';
return '\uD83D\uDCC4';
}
/* ── Selection context threaded through the tree ──────────────────────── */
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
interface SelectionCtx {
selectedItemIds: Set<string>;
selectedFileIds: string[];
selectedFolderIds: string[];
onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void;
onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
}
/* Stable trio (chat | scope | neutralize)
* Always rendered in this order, always at the right edge of the row.
* Each slot has a fixed width so missing actions render an invisible
* placeholder icons never jump position between rows. */
interface StableTrioProps {
scope?: string;
neutralize?: boolean;
scopeLabels: Record<string, string>;
onChat?: () => void;
onScopeChange?: (newScope: string) => void;
onNeutralizeToggle?: (newValue: boolean) => void;
chatTitle: string;
}
function _StableTrio({
scope, neutralize,
scopeLabels,
onChat, onScopeChange, onNeutralizeToggle,
chatTitle,
}: StableTrioProps) {
const { t } = useLanguage();
const _cycleScope = (current: string | undefined) => {
const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal');
return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
};
return (
<span className={styles.stableActions}>
{/* Slot 1: Chat */}
{onChat ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onChat(); }}
title={chatTitle}
style={{ fontSize: 12 }}
>
{'\u{1F4AC}'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
)}
{/* Slot 2: Scope */}
{onScopeChange && scope != null ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
)}
{/* Slot 3: Neutralize */}
{onNeutralizeToggle ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
)}
</span>
);
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const isSelected = sel.selectedItemIds.has(file.id);
const multiSelected = sel.selectedItemIds.size > 1;
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== file.fileName && sel.onRenameFile) {
await sel.onRenameFile(file.id, trimmed);
}
setRenaming(false);
}, [renameValue, file.id, file.fileName, sel.onRenameFile]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.onDeleteFile) await sel.onDeleteFile(file.id);
}, [file.id, sel]);
return (
<div
className={[
styles.treeNode,
styles.fileNode,
isSelected ? styles.multiSelected : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ')}
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
draggable
onDragStart={(e) => {
sel.onItemDragStart(e, file.id, 'file', file.fileName);
setDragging(true);
}}
onDragEnd={() => setDragging(false)}
>
<span className={styles.chevron} style={{ visibility: 'hidden' }}><FaChevronRight /></span>
<span className={styles.fileIcon}>{_fileIcon(file.mimeType)}</span>
{renaming ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{file.fileName}</span>
)}
{!renaming && (
<span className={styles.rightZone}>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
</button>
)}
{multiSelected && isSelected ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={t('{count} Ordner löschen', { count: String(sel.selectedFolderIds.length) })}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<FaTrash />
</button>
)
)}
</span>
<_StableTrio
scope={file.scope}
neutralize={file.neutralize}
scopeLabels={scopeLabels}
onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined}
onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined}
onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined}
chatTitle={t('In Chat senden')}
/>
</span>
)}
</div>
);
}
/* ── Tree node (folder) ───────────────────────────────────────────────── */
interface TreeNodeProps {
node: FolderNode;
depth: number;
selectedFolderId: string | null;
expandedIds: Set<string>;
showFiles: boolean;
filesByFolder: Map<string, FileNode[]>;
sel: SelectionCtx;
promptFolderName: (message: string, options?: PromptOptions) => Promise<string | null>;
onToggle: (id: string) => void;
onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
}
function _TreeNode({
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
promptFolderName,
onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle,
}: TreeNodeProps) {
const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name);
const [dropOver, setDropOver] = useState(false);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const isExpanded = expandedIds.has(node.id);
const isNavSelected = selectedFolderId === node.id;
const isMultiSelected = sel.selectedItemIds.has(node.id);
const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : [];
const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0 || (node.fileCount ?? 0) > 0;
useEffect(() => {
if (renaming && inputRef.current) inputRef.current.focus();
}, [renaming]);
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== node.name && onRenameFolder) {
await onRenameFolder(node.id, trimmed);
}
setRenaming(false);
}, [renameValue, node.id, node.name, onRenameFolder]);
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onCreateFolder) return;
const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
if (name?.trim()) {
await onCreateFolder(name.trim(), node.id);
if (!expandedIds.has(node.id)) onToggle(node.id);
}
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName, t]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteFolder) await onDeleteFolder(node.id);
}, [onDeleteFolder, node.id]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropOver(true);
}, []);
const _handleDragLeave = useCallback(() => setDropOver(false), []);
const _handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) {
await onMoveFolders(folderIds, node.id);
} else if (onMoveFolder) {
for (const fId of folderIds) await onMoveFolder(fId, node.id);
}
if (fileIds.length > 0 && onMoveFiles) {
await onMoveFiles(fileIds, node.id);
} else if (fileIds.length > 0 && onMoveFile) {
for (const fId of fileIds) await onMoveFile(fId, node.id);
}
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && folderId !== node.id && onMoveFolder) {
await onMoveFolder(folderId, node.id);
} else if (fileIdsJson && onMoveFiles) {
await onMoveFiles(JSON.parse(fileIdsJson), node.id);
} else if (fileId && onMoveFile) {
await onMoveFile(fileId, node.id);
}
}, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const nodeClasses = [
styles.treeNode,
isNavSelected && !isMultiSelected ? styles.selected : '',
isMultiSelected ? styles.multiSelected : '',
dropOver ? styles.dropTarget : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ');
const isProtected = node.isProtected === true;
const isReadonly = node.isReadonly === true;
const notDraggable = isProtected || isReadonly;
const notEditable = isProtected || isReadonly;
const customIcon = node.icon;
return (
<div>
<div
className={nodeClasses}
onClick={(e) => sel.onItemClick(node.id, 'folder', e)}
draggable={!notDraggable}
onDragStart={notDraggable ? undefined : (e) => {
sel.onItemDragStart(e, node.id, 'folder', node.name);
setDragging(true);
}}
onDragEnd={notDraggable ? undefined : () => setDragging(false)}
onDragOver={isProtected ? undefined : _handleDragOver}
onDragLeave={isProtected ? undefined : _handleDragLeave}
onDrop={isProtected ? undefined : _handleDrop}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''} ${!hasChildren ? styles.empty : ''}`}
onClick={(e) => { e.stopPropagation(); if (hasChildren) onToggle(node.id); }}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{customIcon ? (
<span style={{ fontSize: 14 }}>{customIcon}</span>
) : isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{renaming && !notEditable ? (
<input
ref={inputRef}
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
)}
{!isProtected && (
<span className={styles.rightZone}>
<span className={styles.actions}>
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
<FaDownload />
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
<FaPlus />
</button>
)}
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : !notEditable && onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<FaTrash />
</button>
)}
</span>
<_StableTrio
scope={node.scope}
neutralize={node.neutralize}
scopeLabels={scopeLabels}
onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined}
onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined}
onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined}
chatTitle={t('In Chat senden')}
/>
</span>
)}
</div>
{isExpanded && hasChildren && (
<div className={styles.children}>
{node.children!.map((child) => (
<_TreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={onToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/>
))}
{folderFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
)}
</div>
);
}
/* ── Root component ────────────────────────────────────────────────────── */
export default function FolderTree({
folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect,
selectedItemIds: externalSelectedIds, onSelectionChange,
expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat,
}: FolderTreeProps) {
const { t } = useLanguage();
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const lastClickedIdRef = useRef<string | null>(null);
const { prompt: promptFolderName, PromptDialog } = usePrompt();
const [rootDropOver, setRootDropOver] = useState(false);
const expandedIds = externalExpandedIds ?? internalExpandedIds;
const selectedItemIds = externalSelectedIds ?? internalSelectedIds;
const realTree = useMemo(() => _buildTree(folders), [folders]);
const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]);
const rootFiles = showFiles ? (filesByFolder.get('') || []) : [];
const knownFolderIds = useMemo(() => {
const ids = new Set<string>();
const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } };
_collect(realTree);
return ids;
}, [realTree]);
const tree = useMemo(() => {
if (!showFiles) return realTree;
const orphanFolders: FolderNode[] = [];
for (const key of filesByFolder.keys()) {
if (key && !knownFolderIds.has(key)) {
orphanFolders.push({ id: key, name: key.slice(0, 8) + '…', parentId: null, fileCount: filesByFolder.get(key)?.length ?? 0, isProtected: true });
}
}
if (orphanFolders.length === 0) return realTree;
return [...realTree, ...orphanFolders.sort((a, b) => a.name.localeCompare(b.name))];
}, [realTree, showFiles, filesByFolder, knownFolderIds]);
const flatList = useMemo(
() => _computeFlatList(tree, expandedIds, showFiles, filesByFolder),
[tree, expandedIds, showFiles, filesByFolder],
);
const _handleToggle = useCallback((id: string) => {
if (onToggleExpand) {
onToggleExpand(id);
return;
}
setInternalExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, [onToggleExpand]);
const _setSelection = useCallback((ids: Set<string>) => {
if (onSelectionChange) {
onSelectionChange(ids);
} else {
setInternalSelectedIds(ids);
}
}, [onSelectionChange]);
const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
const next = new Set(selectedItemIds);
if (next.has(id)) next.delete(id); else next.add(id);
_setSelection(next);
lastClickedIdRef.current = id;
return;
}
if (e.shiftKey && lastClickedIdRef.current) {
const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current);
const currIdx = flatList.findIndex(i => i.id === id);
if (lastIdx >= 0 && currIdx >= 0) {
const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx];
const next = new Set(selectedItemIds);
for (let i = from; i <= to; i++) next.add(flatList[i].id);
_setSelection(next);
}
return;
}
_setSelection(new Set([id]));
lastClickedIdRef.current = id;
if (type === 'folder') onSelect(id);
if (type === 'file') onFileSelect?.(id);
}, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]);
const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => {
const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1;
if (isInSelection) {
const items: TreeItem[] = [];
for (const selId of selectedItemIds) {
const item = flatList.find(i => i.id === selId);
if (item) items.push(item);
}
e.dataTransfer.setData('application/tree-items', JSON.stringify(items));
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
if (fileIds.length > 0) {
e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds));
}
} else {
e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }]));
if (type === 'file') {
e.dataTransfer.setData('application/file-id', id);
} else {
e.dataTransfer.setData('application/folder-id', id);
}
}
e.dataTransfer.effectAllowed = 'copyMove';
}, [selectedItemIds, flatList]);
const allFileIds = useMemo(() => {
const ids = new Set<string>();
for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id);
return ids;
}, [filesByFolder]);
const allFolderIds = useMemo(() => {
const ids = new Set<string>();
const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } };
_collect(tree);
return ids;
}, [tree]);
const sel: SelectionCtx = useMemo(() => {
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
return {
selectedItemIds,
selectedFileIds: selFileIds,
selectedFolderIds: selFolderIds,
onItemClick: _handleItemClick,
onItemDragStart: _handleItemDragStart,
onRenameFile,
onDeleteFile,
onDeleteFiles,
onDeleteFolders,
onScopeChange,
onNeutralizeToggle,
onSendToChat,
};
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]);
// Root drop handler: items dropped on the empty area go to root (null)
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setRootDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) await onMoveFolders(folderIds, null);
else if (onMoveFolder) for (const fId of folderIds) await onMoveFolder(fId, null);
if (fileIds.length > 0 && onMoveFiles) await onMoveFiles(fileIds, null);
else if (onMoveFile) for (const fId of fileIds) await onMoveFile(fId, null);
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && onMoveFolder) await onMoveFolder(folderId, null);
else if (fileId && onMoveFile) await onMoveFile(fileId, null);
}, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const _handleRootAddFolder = useCallback(async () => {
if (!onCreateFolder) return;
const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
if (name?.trim()) await onCreateFolder(name.trim(), null);
}, [onCreateFolder, promptFolderName, t]);
const isRootSelected = selectedFolderId === null;
const _handleRootClick = useCallback(() => {
_setSelection(new Set());
onSelect(null);
}, [_setSelection, onSelect]);
return (
<div className={styles.folderTree}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 4px' }}>
<span
className={`${styles.treeNode} ${isRootSelected ? styles.selected : ''} ${rootDropOver ? styles.dropTarget : ''}`}
style={{ flex: 1, cursor: 'pointer', fontWeight: 600, paddingLeft: 4 }}
onClick={_handleRootClick}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setRootDropOver(true); }}
onDragLeave={() => setRootDropOver(false)}
onDrop={_handleRootDrop}
>
/
</span>
<span className={styles.actions}>
{onCreateFolder && (
<button className={styles.actionBtn} onClick={_handleRootAddFolder} title={t('Neuer Ordner')}>
<FaPlus />
</button>
)}
{onRefresh && (
<button className={styles.actionBtn} onClick={onRefresh} title={t('Aktualisieren')}>
<FaSyncAlt />
</button>
)}
</span>
</div>
<div className={styles.children}>
{tree.map((node) => (
<_TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={_handleToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/>
))}
{rootFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
<PromptDialog />
</div>
);
}

View file

@ -1,319 +0,0 @@
/**
* SharepointBrowseTree Lazy-loading tree for SharePoint browse.
* Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling).
* Loads children on expand via onLoadChildren(path).
*/
import React, { useState, useCallback, useEffect } from 'react';
import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
import styles from './FolderTree.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface BrowseEntry {
name: string;
path: string;
isFolder: boolean;
size?: number;
mimeType?: string;
metadata?: Record<string, unknown>;
}
export interface SharepointBrowseTreeProps {
/** Root path (usually "/") - children loaded via onLoadChildren */
rootPath?: string;
/** Load children for a given path. Returns folders and files. */
onLoadChildren: (path: string) => Promise<BrowseEntry[]>;
/** Called when user selects a file path */
onSelectFile: (path: string) => void;
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
onSelectFolder?: (path: string) => void;
/** If true, file rows are not shown — only folders (for list/upload/destination folder pickers). */
foldersOnly?: boolean;
/** Currently selected path (for highlight) */
selectedPath?: string | null;
/** Optional: pre-seed root children (e.g. from initial load) */
initialChildren?: BrowseEntry[];
}
function _fileIcon(mime?: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
return '\uD83D\uDCC4';
}
/* ── File row ──────────────────────────────────────────────────────────── */
function _FileRow({
entry,
selectedPath,
onSelect,
}: {
entry: BrowseEntry;
selectedPath: string | null | undefined;
onSelect: (path: string) => void;
}) {
const isSelected = selectedPath === entry.path;
return (
<div
className={`${styles.treeNode} ${styles.fileNode} ${isSelected ? styles.selected : ''}`}
onClick={() => onSelect(entry.path)}
title={entry.path}
>
<span className={styles.chevron + ' ' + styles.empty} />
<span className={styles.fileIcon}>{_fileIcon(entry.mimeType)}</span>
<span className={styles.folderName}>{entry.name}</span>
{entry.size != null && (
<span className={styles.fileSize}>
{(entry.size / 1024).toFixed(0)}K
</span>
)}
</div>
);
}
/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */
function _FolderRow({
entry,
selectedPath,
expandedPaths,
loadedChildren,
loadingPaths,
onToggle,
onSelectFile,
onSelectFolder,
foldersOnly,
}: {
entry: BrowseEntry;
selectedPath: string | null | undefined;
expandedPaths: Set<string>;
loadedChildren: Record<string, BrowseEntry[]>;
loadingPaths: Set<string>;
onToggle: (path: string) => void;
onSelectFile: (path: string) => void;
onSelectFolder?: (path: string) => void;
foldersOnly: boolean;
}) {
const { t } = useLanguage();
const isExpanded = expandedPaths.has(entry.path);
const isSelected = selectedPath === entry.path;
const children = loadedChildren[entry.path] ?? [];
const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const isLoading = isExpanded && loadingPaths.has(entry.path);
const handleRowClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest(`.${styles.chevron}`)) return;
if (onSelectFolder) {
onSelectFolder(entry.path);
return;
}
onToggle(entry.path);
};
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggle(entry.path);
};
return (
<div>
<div
className={`${styles.treeNode} ${onSelectFolder && isSelected ? styles.selected : ''}`}
onClick={handleRowClick}
title={entry.path}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
onClick={handleChevronClick}
title={isExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
<span className={styles.folderName}>{entry.name}</span>
{isLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isExpanded && (
<div className={styles.children}>
{isLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
{t('Wird geladen…')}
</div>
) : (
<>
{folders.map((child) => (
<_FolderRow
key={child.path}
entry={child}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
loadedChildren={loadedChildren}
loadingPaths={loadingPaths}
onToggle={onToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
foldersOnly={foldersOnly}
/>
))}
{!foldersOnly &&
files.map((child) => (
<_FileRow
key={child.path}
entry={child}
selectedPath={selectedPath}
onSelect={onSelectFile}
/>
))}
{children.length === 0 && (
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
{t('Leer')}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/* ── Root component ─────────────────────────────────────────────────────── */
export function SharepointBrowseTree({
rootPath = '/',
onLoadChildren,
onSelectFile,
onSelectFolder,
foldersOnly = false,
selectedPath,
initialChildren = [],
}: SharepointBrowseTreeProps) {
const { t } = useLanguage();
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
);
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const loadPath = useCallback(
async (path: string) => {
setLoadingPaths((p) => new Set(p).add(path));
try {
const items = await onLoadChildren(path);
setLoadedChildren((prev) => ({ ...prev, [path]: items }));
} catch {
setLoadedChildren((prev) => ({ ...prev, [path]: [] }));
} finally {
setLoadingPaths((p) => {
const next = new Set(p);
next.delete(path);
return next;
});
}
},
[onLoadChildren]
);
const handleToggle = useCallback(
(path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
loadPath(path);
}
return next;
});
},
[loadPath]
);
useEffect(() => {
if (rootPath in loadedChildren) return;
if (initialChildren.length > 0) return;
loadPath(rootPath);
}, [rootPath, initialChildren.length, loadPath]);
const rootItems = loadedChildren[rootPath] ?? [];
const rootLoading = loadingPaths.has(rootPath);
const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const isRootExpanded = expandedPaths.has(rootPath);
return (
<div className={styles.folderTree}>
<div
className={`${styles.treeNode} ${selectedPath === null || selectedPath === undefined ? styles.selected : ''}`}
style={{ fontWeight: 600 }}
>
<span
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
onClick={() => handleToggle(rootPath)}
title={isRootExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>{t('SharePoint')}</span>
{rootLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isRootExpanded && (
<div className={styles.children}>
{rootLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
{t('Sites werden geladen…')}
</div>
) : (
<>
{rootFolders.map((entry) => (
<_FolderRow
key={entry.path}
entry={entry}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
loadedChildren={loadedChildren}
loadingPaths={loadingPaths}
onToggle={handleToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
foldersOnly={foldersOnly}
/>
))}
{!foldersOnly &&
rootFiles.map((entry) => (
<_FileRow
key={entry.path}
entry={entry}
selectedPath={selectedPath}
onSelect={onSelectFile}
/>
))}
{rootItems.length === 0 && !rootLoading && (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
{t('Keine Einträge')}
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View file

@ -247,6 +247,34 @@
box-shadow: 0 3px 8px rgba(197, 48, 48, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
}
/* Compact mode (sidebar/UDB) */
.compact {
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
min-height: 0 !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: var(--color-text-secondary, #6b7280) !important;
border-radius: 3px !important;
flex-shrink: 0;
}
.compact .actionIcon {
font-size: 12px !important;
width: 12px !important;
height: 12px !important;
filter: none !important;
}
.compact:hover {
background: var(--color-secondary, #4A6FA5) !important;
color: #fff !important;
box-shadow: none !important;
transform: none !important;
}
/* Responsive Design */
@media (max-width: 768px) {
.actionButtons {

View file

@ -10,7 +10,7 @@ import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface
export interface FilterableField {
key: string;
label: string;
label?: string;
type?: AttributeType;
filterable?: boolean;
filterOptions?: string[];
@ -179,9 +179,15 @@ export function FormGeneratorControls({
</div>
)}
{/* Search Controls with Pagination - Hide when items are selected */}
{searchable && selectedCount === 0 && (
{/* Toolbar: optional search + filters badge + CSV + pagination (search is optional) */}
{selectedCount === 0 &&
(searchable ||
(pagination && supportsBackendPagination) ||
!!onCsvExport ||
!!onRefresh ||
activeFiltersCount > 0) && (
<div className={styles.searchContainer}>
{searchable && (
<div className={styles.floatingLabelInput}>
<input
type="text"
@ -196,6 +202,7 @@ export function FormGeneratorControls({
{t('Suchen...')}
</label>
</div>
)}
{activeFiltersCount > 0 && (
<span className={styles.activeFiltersCount}>
{activeFiltersCount} {t('Filter')}

View file

@ -109,6 +109,53 @@
border-color: var(--primary-color, #f25843);
}
/* --- Multiselect chip group --- */
.chipGroup {
display: inline-flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--border-color, #333);
border-radius: 999px;
background: var(--bg-secondary, #2a2a2a);
color: var(--text-secondary, #888);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.chip:hover {
border-color: var(--primary-color, #f25843);
color: var(--text-primary, #e0e0e0);
}
.chipActive {
background: var(--primary-color, #4A6FA5);
border-color: var(--primary-color, #4A6FA5);
color: #fff;
}
.chipActive:hover {
color: #fff;
filter: brightness(0.95);
}
.chipMeta {
font-size: 0.7rem;
color: var(--text-secondary, #888);
margin-left: 0.25rem;
}
/* --- Sections Grid --- */
.sectionsGrid {

View file

@ -6,6 +6,13 @@ import {
} from 'recharts';
import styles from './FormGeneratorReport.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
PeriodPicker,
fromIsoDate,
toIsoDate,
type PeriodPreset,
type PeriodValue,
} from '../../PeriodPicker';
import type {
FormGeneratorReportProps,
@ -531,14 +538,36 @@ const _Toolbar: React.FC<ToolbarProps> = ({
});
};
const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => {
const dateRange = filterState.dateRange || { from: new Date(), to: new Date() };
const _handlePeriodPickerChange = (next: PeriodValue) => {
const fromD = fromIsoDate(next.fromDate) || new Date();
const toD = fromIsoDate(next.toDate) || new Date();
onFilterStateChange({
...filterState,
dateRange: { ...dateRange, [field]: new Date(dateStr) }
dateRange: { from: fromD, to: toD },
periodValue: next,
});
};
// Prefer the preserved PeriodValue (carries the preset) so the round-trip
// back into PeriodPicker does not collapse to `custom`, which would clash
// with `direction: 'past'` for presets whose natural end is in the future
// (e.g. `thisMonth`, `thisQuarter`, `ytd`) and trigger an infinite fallback
// loop in PeriodPicker's constraint-correction effect.
const _periodPickerValue: PeriodValue | null = useMemo(() => {
if (filterState.periodValue) return filterState.periodValue;
const dr = filterState.dateRange;
if (!dr?.from || !dr?.to) return null;
return {
preset: { kind: 'custom' },
fromDate: toIsoDate(dr.from),
toDate: toIsoDate(dr.to),
};
}, [filterState.periodValue, filterState.dateRange]);
const _periodPickerDefault: PeriodPreset = useMemo(() => {
return { kind: dateRangeSelector?.defaultPresetKind || 'ytd' } as PeriodPreset;
}, [dateRangeSelector?.defaultPresetKind]);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
@ -605,22 +634,18 @@ const _Toolbar: React.FC<ToolbarProps> = ({
<div className={styles.toolbarSeparator} />
)}
{/* Date Range */}
{/* Date Range (rendered via shared PeriodPicker) */}
{hasDateRange && (
<div className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>{t('Von')}</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
/>
<span className={styles.toolbarLabel}>{t('Bis')}</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.to?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('to', e.target.value)}
<span className={styles.toolbarLabel}>{t('Zeitraum')}</span>
<PeriodPicker
value={_periodPickerValue}
onChange={_handlePeriodPickerChange}
direction={dateRangeSelector!.direction || 'any'}
defaultPreset={_periodPickerDefault}
enabledPresets={dateRangeSelector!.enabledPresets}
minDate={dateRangeSelector!.minDate}
maxDate={dateRangeSelector!.maxDate}
/>
</div>
)}
@ -642,6 +667,12 @@ const _Toolbar: React.FC<ToolbarProps> = ({
value={(filterState.filters[filter.key] as string) || ''}
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
/>
) : filter.type === 'multiselect' ? (
<_MultiselectChips
filter={filter}
value={filterState.filters[filter.key]}
onChange={(next) => _handleFilterChange(filter.key, next)}
/>
) : (
<select
className={styles.select}
@ -660,6 +691,73 @@ const _Toolbar: React.FC<ToolbarProps> = ({
);
};
// =============================================================================
// MULTISELECT CHIPS
// Renders ``multiselect`` filters as inline toggle chips so the user can:
// - see at a glance which values are active
// - toggle individual values on/off
// - reset to "all" with the leading "Alle"-chip
// Emits the selection upstream as a ``string[]`` matching ``ReportFilterState``.
// =============================================================================
interface _MultiselectChipsProps {
filter: ReportFilterConfig;
value: string | string[] | undefined;
onChange: (next: string[]) => void;
}
const _MultiselectChips: React.FC<_MultiselectChipsProps> = ({ filter, value, onChange }) => {
const { t } = useLanguage();
const selected: Set<string> = useMemo(() => {
if (Array.isArray(value)) return new Set(value.map(String));
if (typeof value === 'string' && value !== '') return new Set([value]);
return new Set<string>();
}, [value]);
const _toggle = (optValue: string) => {
const next = new Set(selected);
if (next.has(optValue)) next.delete(optValue); else next.add(optValue);
onChange(Array.from(next));
};
const _reset = () => onChange([]);
const allLabel = filter.placeholder || t('Alle');
const totalActive = selected.size;
return (
<div className={styles.chipGroup}>
<button
type="button"
className={`${styles.chip} ${totalActive === 0 ? styles.chipActive : ''}`}
onClick={_reset}
title={allLabel}
>
{allLabel}
</button>
{filter.options?.map(opt => {
const active = selected.has(opt.value);
return (
<button
key={opt.value}
type="button"
className={`${styles.chip} ${active ? styles.chipActive : ''}`}
onClick={() => _toggle(opt.value)}
title={opt.label}
>
{opt.label}
</button>
);
})}
{totalActive > 0 && (
<span className={styles.chipMeta}>
{t('{n} aktiv', { n: String(totalActive) })}
</span>
)}
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================

View file

@ -54,7 +54,7 @@ export interface ReportPeriodSelectorConfig {
defaultMonth?: number;
}
/** Date range selector configuration */
/** Date range selector configuration. Renders the shared PeriodPicker. */
export interface ReportDateRangeSelectorConfig {
/** Whether the date range selector is enabled */
enabled: boolean;
@ -62,6 +62,28 @@ export interface ReportDateRangeSelectorConfig {
defaultFrom?: Date;
/** Default to date */
defaultTo?: Date;
/**
* Allowed direction relative to today. Default: `'any'`. Set to `'past'`
* for historic reports (most cases), `'future'` for forecasts.
*/
direction?: 'past' | 'future' | 'any';
/**
* Default preset kind shown when neither `defaultFrom`/`defaultTo` nor a
* stored selection is available. Default: `'ytd'`.
*/
defaultPresetKind?:
| 'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
/** Whitelist of preset kinds offered to the user. */
enabledPresets?: Array<
'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
| 'lastN' | 'nextN' | 'custom'
>;
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
minDate?: string;
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
maxDate?: string;
}
/** Combined filter state passed to the data callback */
@ -72,8 +94,15 @@ export interface ReportFilterState {
year?: number;
/** Selected month (1-12) */
month?: number;
/** Date range */
/** Date range (always synthesized from `periodValue` when the
* `dateRangeSelector` is enabled). */
dateRange?: ReportDateRange;
/**
* Full PeriodPicker value when the `dateRangeSelector` is enabled. Carries
* the original preset (e.g. `thisMonth`) so that the round-trip back into
* the picker preserves preset semantics and does not collapse to `custom`.
*/
periodValue?: import('../../PeriodPicker').PeriodValue;
/** Custom filter values: key -> value(s) */
filters: Record<string, string | string[]>;
}

View file

@ -9,6 +9,13 @@
overflow: hidden;
height: 100%;
max-height: 100%;
position: relative;
}
/* Outer table in “sections” mode: fill flex parent (e.g. billing transactions tab) */
.formGeneratorTableSectionsRoot {
flex: 1;
min-height: 0;
}
.title {
@ -78,6 +85,93 @@
padding: 40px 20px;
}
/* ── Group sections layout (one table per category) ───────────────────── */
.groupSections {
display: flex;
flex-direction: column;
gap: 1.25rem;
width: 100%;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.groupSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
/* Share remaining viewport among expanded groups; scroll when many groups */
flex: 1 1 280px;
min-height: 0;
}
.groupSectionCollapsed {
flex: 0 0 auto;
min-height: unset;
}
.groupSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin: 0;
padding: 8px 4px 4px;
border: none;
border-bottom: 1px solid var(--color-border, #e2e8f0);
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
color: inherit;
border-radius: 4px 4px 0 0;
}
.groupSectionHeader:hover {
background: color-mix(in srgb, var(--color-bg, #fff) 92%, var(--color-border, #e2e8f0) 8%);
}
.groupSectionHeaderLeft {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.groupSectionCaret {
font-size: 11px;
opacity: 0.65;
width: 14px;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.groupSectionTitle {
font-weight: 600;
font-size: 1rem;
color: var(--color-text, inherit);
}
.groupSectionMeta {
font-size: 0.875rem;
color: var(--text-muted, #64748b);
}
.groupSectionsLoading {
padding: 12px 4px;
color: var(--text-muted, #64748b);
}
.groupSectionTableWrap {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.emptyMessage {
text-align: center;
padding: 20px;
@ -133,20 +227,26 @@
}
.table thead tr {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
.th {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary, #64748b);
font-size: 11px;
letter-spacing: 0.02em;
color: var(--color-text-secondary, #475569);
white-space: nowrap;
overflow: visible;
user-select: none;
border-bottom: 2px solid var(--color-border, #e2e8f0);
border-bottom: 2px solid rgba(124, 109, 216, 0.35);
border-right: 1px solid #dde2ea;
}
.th:last-child {
border-right: none;
}
.th.actionsColumn {
@ -159,14 +259,13 @@
}
.th.sortable:hover {
background: #eef0f3;
background: #e4e8ef;
color: var(--color-text, #334155);
}
.headerContent {
display: flex;
align-items: center;
justify-content: left;
gap: 4px;
}
@ -230,8 +329,8 @@
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
max-width: 300px;
min-width: 200px;
max-width: 320px;
background: var(--color-bg);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
@ -303,6 +402,116 @@
font-style: italic;
}
/* Numeric column filter (operator + value / range) */
.filterNumericPanel {
padding: 6px 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filterNumericRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.filterNumericLabel {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #64748b);
}
.filterOperatorSelect,
.filterNumericInput {
width: 100%;
padding: 6px 8px;
font-size: 13px;
font-family: var(--font-family);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.filterOperatorSelect:focus,
.filterNumericInput:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
}
.filterNumericActions {
padding-top: 2px;
}
.filterApplyBtn {
width: 100%;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
font-family: var(--font-family);
cursor: pointer;
border: none;
border-radius: 6px;
background: var(--color-secondary);
color: #fff;
}
.filterApplyBtn:hover {
opacity: 0.92;
}
/* PeriodPicker wrapper inside filter dropdown (date columns).
Rendered as sibling to .filterDropdownOptions so the PeriodPicker
popover (position: absolute, ~720 px) is not clipped by overflow. */
.filterDatePickerWrap {
padding: 6px 8px 8px;
overflow: visible;
}
.filterDatePickerWrap + .filterDropdownOptions {
display: none;
}
/* Date column filter (from / to) — legacy fallback */
.filterDatePanel {
padding: 6px 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filterDateRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.filterDateLabel {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #64748b);
}
.filterDateInput {
width: 100%;
padding: 6px 8px;
font-size: 12px;
font-family: var(--font-family);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.filterDateInput:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
}
.resizeHandle {
position: absolute;
top: 0;
@ -326,7 +535,8 @@
/* Table cells */
.td {
padding: 8px 12px;
border-top: 1px solid var(--color-border, #f1f5f9);
border-top: 1px solid var(--color-border, #e5e9ef);
border-right: 1px solid #eef0f4;
color: var(--color-text);
font-weight: 400;
font-size: 13px;
@ -338,27 +548,27 @@
overflow: visible;
}
.fkLoading {
color: var(--color-text);
opacity: 0.6;
font-style: italic;
.td:last-child {
border-right: none;
}
/* Rows */
.tr {
transition: background-color 0.12s ease;
transition: background-color 0.12s ease, box-shadow 0.12s ease;
}
.tr:hover {
background: var(--color-gray-disabled, #f8fafc);
background: #f0f4ff;
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr:nth-child(even) {
background: rgba(0, 0, 0, 0.015);
background: rgba(0, 0, 0, 0.025);
}
.tr:nth-child(even):hover {
background: var(--color-gray-disabled, #f8fafc);
background: #f0f4ff;
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr.selected {
@ -369,6 +579,56 @@
cursor: pointer;
}
/* Items that live inside a group — subtle tint + left connector */
.tr.groupedItem {
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);
}
.tr.groupedItem:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-bg, #fff));
}
/**
* Hierarchy: set `--row-tree-indent` on the <tr> (px). Same row shifts checkbox, actions, and every `.td`.
* Folder rows attach this class from GroupRow.tsx; omit padding on `.folderCell` (inner strip uses `--group-indent`).
*/
.treeRowIndented {
--row-tree-indent: 0px;
}
.treeRowIndented > .selectColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .actionsColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .td {
box-sizing: border-box !important;
padding-top: 8px !important;
padding-right: 12px !important;
padding-bottom: 8px !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .folderCell:first-child {
box-sizing: border-box !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .selectColumn + .folderCell {
padding: 0 !important;
}
/* Selection Column */
.selectColumn {
text-align: center;
@ -378,7 +638,7 @@
}
thead .selectColumn {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
tbody .selectColumn {
@ -429,7 +689,7 @@ tbody .selectColumn {
}
thead .actionsColumn {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
tbody .actionsColumn {
@ -714,7 +974,11 @@ tbody .actionsColumn {
height: auto;
}
.th,
.th {
padding: 6px 8px;
font-size: 10px;
}
.td {
padding: 6px 8px;
font-size: 12px;
@ -764,29 +1028,40 @@ tbody .actionsColumn {
/* Dark theme */
@media (prefers-color-scheme: dark) {
.table thead tr {
background: #2a2d31;
background: #2d3038;
}
.th {
background: #2a2d31;
border-bottom-color: rgba(255, 255, 255, 0.12);
background: #2d3038;
border-bottom: 2px solid rgba(124, 109, 216, 0.3);
border-right-color: rgba(255, 255, 255, 0.08);
}
.td {
border-right-color: rgba(255, 255, 255, 0.06);
}
thead .selectColumn,
thead .actionsColumn {
background: #2a2d31;
background: #2d3038;
}
.th.sortable:hover {
background: #32363b;
background: #363a42;
}
.tr:hover {
background: rgba(255, 255, 255, 0.04);
background: rgba(124, 109, 216, 0.08);
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
background: rgba(255, 255, 255, 0.03);
}
.tr:nth-child(even):hover {
background: rgba(124, 109, 216, 0.08);
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr.selected {
@ -990,3 +1265,134 @@ tbody .actionsColumn {
gap: 4px;
align-items: center;
}
/* ── Compact sidebar mode ───────────────────────────────────────────────────── */
.compactMode {
gap: 0;
}
.compactMode .tableWrapper {
border: none;
}
/* Switch to auto layout so the action column shrinks to its content width
and the name column fills all remaining space naturally */
.compactMode .table {
table-layout: auto;
}
.compactMode .td {
padding: 5px 8px;
font-size: 12px;
border-right: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Let the browser size this column based on content */
width: auto;
min-width: unset;
max-width: unset;
}
/* Re-apply tree indent for data cells in compact mode */
.compactMode .treeRowIndented > .td {
padding-top: 5px !important;
padding-right: 8px !important;
padding-bottom: 5px !important;
padding-left: calc(8px + var(--row-tree-indent)) !important;
}
/* The action column: fixed narrow width, no background strip */
.compactMode .actionsColumn {
width: 28px !important;
min-width: 0 !important;
max-width: 28px !important;
padding: 2px !important;
background: transparent !important;
overflow: hidden;
white-space: nowrap;
}
.compactMode .actionButtons {
display: inline-flex !important;
width: auto !important;
gap: 0 !important;
justify-content: center;
}
/* Re-apply tree indent for action column in compact mode (overrides the default padding above) */
.compactMode .treeRowIndented > .actionsColumn {
padding: 2px !important;
}
/* Tighten group rows in compact mode */
.compactMode :global(.groupRow) {
font-size: 11px;
padding: 4px 8px;
}
/* Group bands (server-side view grouping — ClickUp-style) */
.groupBandHeaderRow {
cursor: pointer;
user-select: none;
background: color-mix(in srgb, var(--color-bg, #fff) 88%, var(--color-border, #e2e8f0) 12%);
}
.groupBandHeaderCell {
padding: 8px 14px !important;
border-bottom: 1px solid var(--color-border, #e2e8f0);
vertical-align: middle;
}
.groupBandInner {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: var(--color-text, #0f172a);
}
.groupBandCaret {
font-size: 11px;
opacity: 0.65;
width: 14px;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.groupBandPill {
display: inline-flex;
align-items: center;
max-width: min(420px, 72%);
padding: 5px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.25;
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 16%, transparent);
color: color-mix(in srgb, var(--color-primary, #2f4364) 95%, #fff);
border: 1px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 32%, transparent);
}
.groupBandPath {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
opacity: 0.92;
}
.groupBandPathSep {
opacity: 0.45;
font-weight: 500;
}
.groupBandCount {
margin-left: auto;
font-weight: 500;
font-size: 12px;
opacity: 0.5;
flex-shrink: 0;
}

View file

@ -0,0 +1,656 @@
.formGeneratorTree {
display: flex;
flex-direction: column;
width: 100%;
font-family: var(--font-family);
min-height: 0;
flex: 1;
overflow: hidden;
height: 100%;
max-height: 100%;
position: relative;
}
/* Section header */
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--color-border, #e2e8f0);
background: var(--table-header-bg, #edf0f5);
border-radius: 8px 8px 0 0;
}
.sectionHeader:hover {
background: #e4e8ef;
}
.sectionHeaderNonCollapsible {
cursor: default;
}
.sectionHeaderNonCollapsible:hover {
background: var(--table-header-bg, #edf0f5);
}
.collapseChevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: var(--color-text-secondary, #64748b);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.collapseChevronExpanded {
transform: rotate(90deg);
}
.sectionTitle {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-text-secondary, #475569);
flex: 1;
}
.sectionCount {
font-size: 11px;
font-weight: 400;
color: var(--color-text-secondary, #94a3b8);
}
.refreshBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-secondary, #94a3b8);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
margin-left: 2px;
flex-shrink: 0;
}
.refreshBtn:hover {
background: rgba(0, 0, 0, 0.06);
color: var(--color-text, #334155);
}
/* Filter row */
.filterRow {
display: flex;
align-items: center;
padding: 4px 8px;
gap: 4px;
position: relative;
}
.filterInput {
flex: 1;
padding: 4px 24px 4px 8px;
font-size: 12px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 4px;
background: var(--color-bg, #fff);
color: var(--color-text, #334155);
outline: none;
}
.filterInput:focus {
border-color: var(--primary-color, #F25843);
box-shadow: 0 0 0 1px var(--primary-color, #F25843);
}
.filterInput::placeholder {
color: var(--color-text-muted, #94a3b8);
}
.filterClear {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--color-text-muted, #94a3b8);
padding: 0 2px;
line-height: 1;
}
.filterClear:hover {
color: var(--color-text, #334155);
}
/* Tree wrapper */
.treeWrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
background: var(--color-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
/* Tree content (scrollable) */
.treeContent {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.treeContent::-webkit-scrollbar {
width: 6px;
}
.treeContent::-webkit-scrollbar-track {
background: transparent;
}
.treeContent::-webkit-scrollbar-thumb {
background: var(--color-border, #cbd5e1);
border-radius: 3px;
}
.treeContent::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary, #94a3b8);
}
/* Batch action toolbar */
.batchToolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--table-header-bg, #edf0f5);
border-bottom: 1px solid var(--color-border, #e2e8f0);
flex-shrink: 0;
flex-wrap: wrap;
}
.batchCount {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary, #475569);
margin-right: 4px;
white-space: nowrap;
}
.batchButton {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text, #334155);
font-size: 12px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.batchButton:hover {
background: var(--color-gray-disabled, #f1f5f9);
border-color: var(--color-text-secondary, #94a3b8);
}
.batchButtonDanger {
color: #dc2626;
border-color: rgba(220, 38, 38, 0.3);
}
.batchButtonDanger:hover {
background: rgba(220, 38, 38, 0.06);
border-color: #dc2626;
}
.batchButtonIcon {
font-size: 12px;
display: inline-flex;
}
.batchButtonCount {
font-size: 10px;
font-weight: 700;
margin-left: 2px;
opacity: 0.8;
}
/* Node row */
.nodeRow {
display: flex;
align-items: center;
gap: 4px;
height: 36px;
padding: 0 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.12s ease;
position: relative;
border-bottom: 1px solid transparent;
}
.nodeRowCompact {
height: 32px;
}
.nodeRow:hover {
background: #f0f4ff;
}
.nodeRowSelected {
background: rgba(var(--color-secondary-rgb), 0.08);
}
.nodeRowSelected:hover {
background: rgba(var(--color-secondary-rgb), 0.12);
}
.nodeRowFocused {
box-shadow: inset 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
border-radius: 2px;
}
.nodeRowDragOver {
background: rgba(var(--color-secondary-rgb), 0.06);
border: 1px dashed var(--color-secondary);
border-radius: 4px;
}
.nodeRowDragging {
opacity: 0.5;
}
.nodeRowOrphan {
border-left: 2px solid #f59e0b;
}
/* Indent spacer */
.indentSpacer {
flex-shrink: 0;
}
/* Checkbox */
.nodeCheckbox {
flex-shrink: 0;
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-secondary);
margin: 0;
}
/* Expand/collapse chevron */
.expandChevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 10px;
color: var(--color-text-secondary, #64748b);
cursor: pointer;
flex-shrink: 0;
border-radius: 3px;
transition: transform 0.15s ease, background 0.15s ease;
}
.expandChevron:hover {
background: rgba(0, 0, 0, 0.06);
}
.expandChevronExpanded {
transform: rotate(90deg);
}
.expandChevronPlaceholder {
width: 18px;
flex-shrink: 0;
}
/* Node icon */
.nodeIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 14px;
color: var(--color-text-secondary, #64748b);
flex-shrink: 0;
}
/* Node name */
.nodeName {
flex: 1;
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* File size column */
.nodeSize {
width: 52px;
flex-shrink: 0;
font-size: 10px;
color: var(--color-text-muted, #94a3b8);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Orphan badge */
.orphanBadge {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #f59e0b;
margin-left: 2px;
flex-shrink: 0;
}
/* Inline rename input */
.renameInput {
flex: 1;
font-size: 13px;
font-family: var(--font-family);
padding: 2px 6px;
border: 1px solid var(--color-secondary);
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
min-width: 0;
}
/* Hover action icons (download, delete) -- only visible on hover, left of persistent */
.nodeActionsHover {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
.nodeRow:hover .nodeActionsHover {
opacity: 1;
}
/* Persistent action icons (scope, neutralize) -- always visible, right-aligned */
.nodeActionsPersistent {
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
margin-left: auto;
}
.nodeActionBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-secondary, #94a3b8);
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.nodeActionBtn:hover {
background: rgba(0, 0, 0, 0.06);
color: var(--color-text, #334155);
}
.nodeActionBtnDanger:hover {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
/* Emoji button (scope, neutralize) -- matches SourcesTab style */
.emojiBtn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
width: 22px;
text-align: center;
}
.emojiBtnReadonly {
cursor: default;
opacity: 0.35;
}
/* Loading */
.loadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--color-text-secondary, #64748b);
}
.loadingSpinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border, #e2e8f0);
border-top: 2px solid var(--color-text-secondary, #64748b);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.nodeLoadingIndicator {
font-size: 10px;
color: var(--color-text-secondary, #94a3b8);
padding: 4px 0;
padding-left: 24px;
}
/* Empty state */
.emptyState {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.emptyMessage {
text-align: center;
color: var(--color-text);
opacity: 0.5;
font-size: 13px;
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 */
.compactMode .sectionHeader {
padding: 6px 8px;
}
.compactMode .sectionTitle {
font-size: 11px;
}
.compactMode .treeWrapper {
border: none;
box-shadow: none;
}
.compactMode .nodeRow {
padding: 0 6px;
}
.compactMode .nodeName {
font-size: 12px;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
.sectionHeader {
background: #2d3038;
}
.sectionHeader:hover {
background: #363a42;
}
.sectionHeaderNonCollapsible:hover {
background: #2d3038;
}
.nodeRow:hover {
background: rgba(124, 109, 216, 0.08);
}
.nodeRowSelected {
background: rgba(var(--color-secondary-rgb), 0.15);
}
.expandChevron:hover {
background: rgba(255, 255, 255, 0.08);
}
.nodeActionBtn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--color-text, #e2e8f0);
}
.nodeActionBtnDanger:hover {
background: rgba(220, 38, 38, 0.15);
}
.batchToolbar {
background: #2d3038;
}
.batchButton {
background: #363a42;
border-color: rgba(255, 255, 255, 0.1);
color: var(--color-text, #e2e8f0);
}
.batchButton:hover {
background: #3e424b;
}
}
/* Responsive */
@media (max-width: 768px) {
.nodeRow {
height: 36px;
padding: 0 6px;
}
.nodeName {
font-size: 12px;
}
.nodeSize {
display: none;
}
.nodeActionBtn {
width: 24px;
height: 24px;
font-size: 13px;
}
.emojiBtn {
width: 24px;
font-size: 13px;
}
.batchToolbar {
padding: 4px 8px;
flex-wrap: wrap;
}
.batchButton {
padding: 3px 8px;
font-size: 11px;
}
.filterInput {
font-size: 14px;
padding: 6px 24px 6px 8px;
}
.sectionHeader {
padding: 8px;
}
}
/* Touch devices: always show hover actions (no hover on touch) */
@media (pointer: coarse) {
.nodeActionsHover {
opacity: 1;
}
}
/* Accessibility */
.nodeActionBtn:focus-visible,
.expandChevron:focus-visible {
outline: 2px solid var(--color-secondary);
outline-offset: 1px;
}
.nodeRow:focus-visible {
box-shadow: inset 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,783 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormGeneratorTree } from '../FormGeneratorTree';
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
const { mockPrompt } = vi.hoisted(() => ({
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
}));
vi.mock('../../../../hooks/usePrompt', () => ({
usePrompt: () => ({
prompt: mockPrompt,
PromptDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const _ownFolder: TreeNode = {
id: 'f1',
name: 'My Folder',
type: 'folder',
parentId: null,
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _ownFile: TreeNode = {
id: 'file1',
name: 'doc.pdf',
type: 'file',
parentId: 'f1',
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _sharedFolder: TreeNode = {
id: 'sf1',
name: 'Shared Folder',
type: 'folder',
parentId: null,
ownership: 'shared',
scope: 'mandate',
neutralize: false,
};
const _orphanFile: TreeNode = {
id: 'of1',
name: 'orphan.txt',
type: 'file',
parentId: null,
ownership: 'shared',
scope: 'mandate',
contextOrphan: true,
};
// ---------------------------------------------------------------------------
// Mock Provider Factory
// ---------------------------------------------------------------------------
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
return {
rootKey: 'test',
loadChildren: vi.fn(async (parentId) =>
nodes.filter((n) => n.parentId === parentId),
),
canCreate: vi.fn(() => true),
canRename: vi.fn((node) => node.ownership === 'own'),
canDelete: vi.fn((node) => node.ownership === 'own'),
canMove: vi.fn(() => true),
canPatchScope: vi.fn((node) => node.ownership === 'own'),
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
createChild: vi.fn(async (parentId, name) => ({
id: 'new-1',
name,
type: 'folder',
parentId,
ownership: 'own' as const,
scope: 'personal' as const,
})),
renameNode: vi.fn(async () => {}),
deleteNodes: vi.fn(async () => {}),
moveNodes: vi.fn(async () => {}),
patchScope: vi.fn(async () => {}),
patchNeutralize: vi.fn(async () => {}),
getBatchActions: vi.fn(() => []),
};
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
describe('FormGeneratorTree', () => {
describe('Rendering', () => {
beforeEach(() => {
mockPrompt.mockClear();
mockPrompt.mockResolvedValue('NeuOrdner');
});
it('renders tree with title and node count', async () => {
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree provider={provider} ownership="own" title="Documents" />,
);
await waitFor(() => {
expect(screen.getByText('Documents')).toBeInTheDocument();
});
expect(screen.getByText('1')).toBeInTheDocument();
});
it('shows loading spinner while loading', () => {
const provider = _createMockProvider([]);
provider.loadChildren = vi.fn(() => new Promise(() => {})); // never resolves
render(<FormGeneratorTree provider={provider} ownership="own" />);
const tree = screen.getByRole('tree');
expect(tree.querySelector('[class*="loadingSpinner"]')).toBeInTheDocument();
});
it('shows empty message when no nodes', async () => {
const provider = _createMockProvider([]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
emptyMessage="Nothing here"
/>,
);
await waitFor(() => {
expect(screen.getByText('Nothing here')).toBeInTheDocument();
});
});
it('shows default empty message when no custom message', async () => {
const provider = _createMockProvider([]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('No items')).toBeInTheDocument();
});
});
it('renders nodes with correct names', async () => {
const provider = _createMockProvider([_ownFolder, _sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
it('renders nested nodes with indentation when folder expanded', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const expandBtn = screen.getByRole('treeitem', { name: /My Folder/i })
.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
});
it('shows contextOrphan badge for orphan nodes', async () => {
const provider = _createMockProvider([_orphanFile]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('orphan.txt')).toBeInTheDocument();
});
expect(screen.getByTitle('Context orphan')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// New folder
// ---------------------------------------------------------------------------
describe('New folder', () => {
beforeEach(() => {
mockPrompt.mockClear();
mockPrompt.mockResolvedValue('NeuOrdner');
});
it('shows header button when titled own tree has createChild', async () => {
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Documents" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.getByTitle('Neuer Ordner')).toBeInTheDocument();
});
it('does not show new folder for shared tree', async () => {
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" title="Shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
});
it('calls createChild at root when nothing selected', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByTitle('Neuer Ordner'));
await waitFor(() => {
expect(provider.createChild).toHaveBeenCalledWith(null, 'NeuOrdner');
});
});
it('calls createChild under selected folder', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
await user.click(screen.getByTitle('Neuer Ordner'));
await waitFor(() => {
expect(provider.createChild).toHaveBeenCalledWith('f1', 'NeuOrdner');
});
});
it('hides button when allowCreateFolder is false', async () => {
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Docs"
allowCreateFolder={false}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
describe('Selection', () => {
it('click selects a node', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
expect(onSelectionChange).toHaveBeenCalledWith(
expect.objectContaining({ has: expect.any(Function) }),
);
const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
});
it('ctrl+click adds to selection', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const secondNode: TreeNode = {
id: 'f2',
name: 'Other Folder',
type: 'folder',
parentId: null,
ownership: 'own',
scope: 'personal',
};
const provider = _createMockProvider([_ownFolder, secondNode]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('treeitem', { name: /My Folder/i }));
fireEvent.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
ctrlKey: true,
});
const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('f2')).toBe(true);
});
it('second click on folder with cascaded child selection keeps cascaded selection (own)', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
// Expand folder first
const expandBtn = screen.getByRole('treeitem', { name: /My Folder/i })
.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
// Select folder (cascades to children in own mode)
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
let lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('file1')).toBe(true);
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('file1')).toBe(true);
});
it('selection in shared tree does NOT cascade to children', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const sharedChild: TreeNode = {
id: 'sc1',
name: 'child.txt',
type: 'file',
parentId: 'sf1',
ownership: 'shared',
scope: 'mandate',
};
const provider = _createMockProvider([_sharedFolder, sharedChild]);
render(
<FormGeneratorTree
provider={provider}
ownership="shared"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
// Expand folder
const expandBtn = screen.getByRole('treeitem', { name: /Shared Folder/i })
.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('child.txt')).toBeInTheDocument();
});
// Click folder in shared mode
await user.click(screen.getByRole('treeitem', { name: /Shared Folder/i }));
const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('sf1')).toBe(true);
expect(lastCall.has('sc1')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Expand/Collapse
// ---------------------------------------------------------------------------
describe('Expand/Collapse', () => {
it('clicking chevron expands folder and loads children lazily', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
const row = screen.getByRole('treeitem', { name: /My Folder/i });
const expandBtn = row.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
expect(provider.loadChildren).toHaveBeenCalledWith('f1', 'own');
});
it('clicking expanded folder collapses it', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /My Folder/i });
const expandBtn = row.querySelector('[role="button"]')!;
// Expand
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
// Collapse
await user.click(expandBtn);
await waitFor(() => {
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
});
});
// ---------------------------------------------------------------------------
// Inline Rename
// ---------------------------------------------------------------------------
describe('Inline Rename', () => {
it('double-click on own node starts rename', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i }));
await waitFor(() => {
expect(screen.getByDisplayValue('My Folder')).toBeInTheDocument();
});
});
it('enter confirms rename and calls provider.renameNode', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i }));
const input = await screen.findByDisplayValue('My Folder');
await user.clear(input);
await user.type(input, 'Renamed{Enter}');
await waitFor(() => {
expect(provider.renameNode).toHaveBeenCalledWith('f1', 'Renamed');
});
});
it('escape cancels rename', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i }));
const input = await screen.findByDisplayValue('My Folder');
await user.type(input, '{Escape}');
await waitFor(() => {
expect(screen.queryByDisplayValue('My Folder')).not.toBeInTheDocument();
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(provider.renameNode).not.toHaveBeenCalled();
});
it('double-click on shared node does NOT start rename', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /Shared Folder/i }));
expect(screen.queryByDisplayValue('Shared Folder')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
describe('Delete', () => {
beforeEach(() => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('delete button calls provider.deleteNodes', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /My Folder/i });
const deleteBtn = within(row).getByTitle('Loeschen');
await user.click(deleteBtn);
await waitFor(() => {
expect(provider.deleteNodes).toHaveBeenCalledWith(['f1']);
});
});
it('no delete button shown for shared nodes', async () => {
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
expect(within(row).queryByTitle('Loeschen')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Scope Cycling
// ---------------------------------------------------------------------------
describe('Scope Cycling', () => {
it('clicking scope icon cycles through values', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const scopeBtn = screen.getByTitle('Scope: personal');
await user.click(scopeBtn);
await waitFor(() => {
expect(provider.patchScope).toHaveBeenCalledWith(
['f1'],
'featureInstance',
);
});
});
it('scope icon is readonly in shared tree', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
const scopeBtn = screen.getByTitle('Scope: mandate');
await user.click(scopeBtn);
expect(provider.patchScope).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Neutralize Toggle
// ---------------------------------------------------------------------------
describe('Neutralize Toggle', () => {
it('clicking neutralize icon toggles value', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
await waitFor(() => {
expect(provider.patchNeutralize).toHaveBeenCalledWith(['f1'], true);
});
});
it('neutralize icon is readonly in shared tree', async () => {
const user = userEvent.setup();
const sharedNodeWithNeutralize: TreeNode = {
..._sharedFolder,
neutralize: false,
};
const provider = _createMockProvider([sharedNodeWithNeutralize]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
expect(provider.patchNeutralize).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Collapsible Section
// ---------------------------------------------------------------------------
describe('Collapsible Section', () => {
it('section collapses when clicking header', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Documents"
collapsible
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByText('Documents'));
await waitFor(() => {
expect(screen.queryByText('My Folder')).not.toBeInTheDocument();
});
});
it('section expands when clicking header again', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Documents"
collapsible
defaultCollapsed
/>,
);
// Initially collapsed
expect(screen.queryByRole('tree')).not.toBeInTheDocument();
await user.click(screen.getByText('Documents'));
await waitFor(() => {
expect(screen.getByRole('tree')).toBeInTheDocument();
});
});
});
// ---------------------------------------------------------------------------
// Batch Actions
// ---------------------------------------------------------------------------
describe('Batch Actions', () => {
it('batch toolbar appears when items selected', async () => {
const user = userEvent.setup();
const batchAction: TreeBatchAction = {
key: 'export',
label: 'Export',
onClick: vi.fn(),
};
const provider = _createMockProvider([_ownFolder]);
provider.getBatchActions = vi.fn(() => [batchAction]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByText('Export')).not.toBeInTheDocument();
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
await waitFor(() => {
expect(screen.getByText(/selected/i)).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument();
});
});
it('batch actions filtered by ownership', async () => {
const user = userEvent.setup();
const ownOnlyAction: TreeBatchAction = {
key: 'delete-all',
label: 'Delete All',
danger: true,
ownershipFilter: 'own',
onClick: vi.fn(),
};
const provider = _createMockProvider([_sharedFolder]);
provider.getBatchActions = vi.fn(() => [ownOnlyAction]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /Shared Folder/i }));
// The action has ownershipFilter='own' but we're in 'shared' mode, so it's filtered out
await waitFor(() => {
const lastCall = provider.getBatchActions as ReturnType<typeof vi.fn>;
expect(lastCall).toHaveBeenCalled();
});
expect(screen.queryByText('Delete All')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,196 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { FormGeneratorTree } from '../FormGeneratorTree';
import type { TreeNode, TreeNodeProvider } from '../types';
vi.mock('../../../../hooks/usePrompt', () => ({
usePrompt: () => ({
prompt: vi.fn(() => Promise.resolve('x')),
PromptDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const _ownFolder: TreeNode = {
id: 'f1',
name: 'Target Folder',
type: 'folder',
parentId: null,
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _ownFile: TreeNode = {
id: 'file1',
name: 'doc.pdf',
type: 'file',
parentId: null,
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _sharedFolder: TreeNode = {
id: 'sf1',
name: 'Shared Folder',
type: 'folder',
parentId: null,
ownership: 'shared',
scope: 'mandate',
neutralize: false,
};
const _sharedFile: TreeNode = {
id: 'sfile1',
name: 'shared.pdf',
type: 'file',
parentId: null,
ownership: 'shared',
scope: 'mandate',
neutralize: false,
};
// ---------------------------------------------------------------------------
// Mock Provider Factory
// ---------------------------------------------------------------------------
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
return {
rootKey: 'test',
loadChildren: vi.fn(async (parentId) =>
nodes.filter((n) => n.parentId === parentId),
),
canCreate: vi.fn(() => true),
canRename: vi.fn((node) => node.ownership === 'own'),
canDelete: vi.fn((node) => node.ownership === 'own'),
canMove: vi.fn(() => true),
canPatchScope: vi.fn((node) => node.ownership === 'own'),
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
createChild: vi.fn(async (parentId, name) => ({
id: 'new-1',
name,
type: 'folder',
parentId,
ownership: 'own' as const,
scope: 'personal' as const,
})),
renameNode: vi.fn(async () => {}),
deleteNodes: vi.fn(async () => {}),
moveNodes: vi.fn(async () => {}),
patchScope: vi.fn(async () => {}),
patchNeutralize: vi.fn(async () => {}),
getBatchActions: vi.fn(() => []),
};
}
// ---------------------------------------------------------------------------
// Drag and Drop Helpers
// ---------------------------------------------------------------------------
function _createDataTransfer(data: Record<string, string> = {}): DataTransfer {
const store: Record<string, string> = { ...data };
return {
setData: vi.fn((type: string, val: string) => {
store[type] = val;
}),
getData: vi.fn((type: string) => store[type] ?? ''),
effectAllowed: 'uninitialized',
dropEffect: 'none',
clearData: vi.fn(),
items: [] as unknown as DataTransferItemList,
types: Object.keys(store),
files: [] as unknown as FileList,
setDragImage: vi.fn(),
} as unknown as DataTransfer;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FormGeneratorTree - Drag and Drop', () => {
it('drag start sets MIME application/x-poweron-tree-items with correct payload', async () => {
const provider = _createMockProvider([_ownFile, _ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /doc\.pdf/i });
const dataTransfer = _createDataTransfer();
fireEvent.dragStart(row, { dataTransfer });
expect(dataTransfer.setData).toHaveBeenCalledWith(
'application/x-poweron-tree-items',
expect.any(String),
);
const payload = JSON.parse(
(dataTransfer.setData as ReturnType<typeof vi.fn>).mock.calls[0][1],
);
expect(payload).toEqual([
{
id: 'file1',
type: 'file',
name: 'doc.pdf',
providerKey: 'test',
},
]);
});
it('drop on folder calls provider.moveNodes', async () => {
const provider = _createMockProvider([_ownFile, _ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
const targetRow = screen.getByRole('treeitem', { name: /Target Folder/i });
const dragPayload = JSON.stringify([
{ id: 'file1', type: 'file', name: 'doc.pdf', providerKey: 'test' },
]);
const dataTransfer = _createDataTransfer({
'application/x-poweron-tree-items': dragPayload,
});
fireEvent.dragOver(targetRow, { dataTransfer });
fireEvent.drop(targetRow, { dataTransfer });
await waitFor(() => {
expect(provider.moveNodes).toHaveBeenCalledWith(['file1'], 'f1');
});
});
it('drop in shared tree is blocked (no move call)', async () => {
const provider = _createMockProvider([_sharedFile, _sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('shared.pdf')).toBeInTheDocument();
});
const targetRow = screen.getByRole('treeitem', { name: /Shared Folder/i });
const dragPayload = JSON.stringify([
{ id: 'sfile1', type: 'file', name: 'shared.pdf', providerKey: 'test' },
]);
const dataTransfer = _createDataTransfer({
'application/x-poweron-tree-items': dragPayload,
});
fireEvent.dragOver(targetRow, { dataTransfer });
fireEvent.drop(targetRow, { dataTransfer });
// In shared tree, the dragOver handler returns early without calling preventDefault
// so drop won't trigger moveNodes
expect(provider.moveNodes).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,9 @@
export { FormGeneratorTree } from './FormGeneratorTree';
export type {
TreeNode,
TreeNodeProvider,
TreeBatchAction,
FormGeneratorTreeProps,
Ownership,
ScopeValue,
} from './types';

View file

@ -0,0 +1,270 @@
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
import api from '../../../../api';
import { getUserDataCache } from '../../../../utils/userCache';
interface FolderData {
id: string;
name: string;
parentId?: string | null;
scope?: ScopeValue;
neutralize?: boolean;
contextOrphan?: boolean;
}
interface FileData {
id: string;
fileName: string;
folderId?: string | null;
fileSize?: number;
scope?: ScopeValue;
neutralize?: boolean;
contextOrphan?: boolean;
sysCreatedBy?: string;
}
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 {
id: folder.id,
name: folder.name,
type: 'folder',
parentId: folder.parentId ?? null,
ownership,
scope: folder.scope,
neutralize: folder.neutralize,
contextOrphan: folder.contextOrphan,
icon: <FaFolder />,
hasSubfoldersInApiTree,
mayHaveLazyFileChildren,
};
}
function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode {
return {
id: file.id,
name: file.fileName,
type: 'file',
parentId: file.folderId ?? null,
ownership,
scope: file.scope,
neutralize: file.neutralize,
contextOrphan: file.contextOrphan,
sizeBytes: file.fileSize,
icon: <FaFile />,
};
}
export function createFolderFileProvider(options: { includeFiles?: boolean } = {}): TreeNodeProvider {
const includeFiles = options.includeFiles !== false;
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
const typeMap = new Map<string, 'folder' | 'file'>();
function _trackTypes(nodes: TreeNode[]) {
for (const n of nodes) {
typeMap.set(n.id, n.type as 'folder' | 'file');
}
}
function _isFile(id: string): boolean {
return typeMap.get(id) === 'file';
}
return {
rootKey: 'files',
async loadChildren(parentId, ownership) {
const owner = ownerParam(ownership);
const nodes: TreeNode[] = [];
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
const allFolders: FolderData[] = foldersRes.data ?? [];
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles)));
if (includeFiles) {
try {
const filters: Record<string, any> = {};
if (parentId) {
filters.folderId = parentId;
}
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) === parentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
} catch {
// file list may fail for shared trees; folders still render
}
}
_trackTypes(nodes);
return nodes;
},
canCreate(_parentId: string | null) {
return true;
},
canRename(node) {
return node.ownership === 'own';
},
canDelete(node) {
return node.ownership === 'own';
},
canMove(source, target) {
if (source.ownership !== 'own') return false;
if (target && target.type !== 'folder') return false;
if (target && target.id === source.id) return false;
return true;
},
canPatchScope(node) {
return node.ownership === 'own';
},
canPatchNeutralize(node) {
return node.ownership === 'own';
},
async createChild(parentId, name) {
const res = await api.post('/api/files/folders', { name, parentId });
const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
typeMap.set(node.id, 'folder');
return node;
},
async renameNode(id, newName) {
if (_isFile(id)) {
await api.put(`/api/files/${id}`, { fileName: newName });
} else {
await api.patch(`/api/files/folders/${id}`, { name: newName });
}
},
async deleteNodes(ids) {
await Promise.all(ids.map((id) => {
if (_isFile(id)) return api.delete(`/api/files/${id}`);
return api.delete(`/api/files/folders/${id}`);
}));
},
async moveNodes(ids, targetParentId) {
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
return api.post(`/api/files/folders/${id}/move`, { parentId: targetParentId });
}),
);
},
async patchScope(ids, scope, cascadeChildren) {
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.patch(`/api/files/${id}/scope`, { scope });
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren });
}),
);
},
async downloadNode(node) {
if (node.type === 'folder') return;
const res = await api.get(`/api/files/${node.id}/download`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = node.name;
a.click();
window.URL.revokeObjectURL(url);
},
async patchNeutralize(ids, neutralize) {
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.patch(`/api/files/${id}/neutralize`, { neutralize });
return api.patch(`/api/files/folders/${id}/neutralize`, { neutralize });
}),
);
},
getBatchActions(): TreeBatchAction[] {
return [
{
key: 'delete-folders',
label: 'Ordner',
icon: <><FaFolder style={{ fontSize: 10, marginRight: 1 }} /><FaTrash /></>,
danger: true,
ownershipFilter: 'own',
typeFilter: 'folder',
async onClick(folderIds) {
await Promise.all(folderIds.map((id) => api.delete(`/api/files/folders/${id}`)));
},
},
{
key: 'delete-files',
label: 'Dateien',
icon: <FaTrash />,
danger: true,
ownershipFilter: 'own',
typeFilter: 'file',
async onClick(fileIds) {
if (fileIds.length === 1) {
await api.delete(`/api/files/${fileIds[0]}`);
} else {
await api.post('/api/files/batch-delete', { fileIds });
}
},
},
{
key: 'download',
label: 'Download',
async onClick(selectedIds) {
const folderIds = selectedIds.filter((id) => typeMap.get(id) === 'folder');
const fileIds = selectedIds.filter((id) => typeMap.get(id) !== 'folder');
if (fileIds.length === 1 && folderIds.length === 0) {
const res = await api.get(`/api/files/${fileIds[0]}/download`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
const disposition = res.headers?.['content-disposition'] ?? '';
const match = disposition.match(/filename\*?=(?:UTF-8'')?(.+)/i);
a.download = match ? decodeURIComponent(match[1]) : fileIds[0];
a.click();
window.URL.revokeObjectURL(url);
} else {
const res = await api.post(
'/api/files/batch-download',
{ fileIds, folderIds },
{ responseType: 'blob' },
);
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = 'download.zip';
a.click();
window.URL.revokeObjectURL(url);
}
},
},
];
},
};
}
export default createFolderFileProvider;

View file

@ -0,0 +1,87 @@
export type Ownership = 'own' | 'shared';
export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global';
export interface TreeNode<T = any> {
id: string;
name: string;
type: string;
parentId: string | null;
ownership: Ownership;
scope?: ScopeValue;
neutralize?: boolean;
contextOrphan?: boolean;
icon?: React.ReactNode;
children?: TreeNode<T>[];
isLoading?: boolean;
sizeBytes?: number;
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 {
key: string;
label: string;
icon?: React.ReactNode;
danger?: boolean;
ownershipFilter?: Ownership;
typeFilter?: string;
onClick: (selectedIds: string[]) => void | Promise<void>;
}
export interface TreeNodeProvider<T = any> {
rootKey: string;
loadChildren(parentId: string | null, ownership: Ownership): Promise<TreeNode<T>[]>;
canCreate?(parentId: string | null): boolean;
canRename?(node: TreeNode<T>): boolean;
canDelete?(node: TreeNode<T>): boolean;
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
canPatchScope?(node: TreeNode<T>): boolean;
canPatchNeutralize?(node: TreeNode<T>): boolean;
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
renameNode?(id: string, newName: string): Promise<void>;
deleteNodes?(ids: string[]): Promise<void>;
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
downloadNode?(node: TreeNode<T>): Promise<void>;
getBatchActions?(): TreeBatchAction[];
}
export interface FormGeneratorTreeProps<T = any> {
provider: TreeNodeProvider<T>;
ownership: Ownership;
title?: string;
compact?: boolean;
collapsible?: boolean;
defaultCollapsed?: boolean;
emptyMessage?: string;
showFilter?: boolean;
onNodeClick?: (node: TreeNode<T>) => void;
onSelectionChange?: (selectedIds: Set<string>) => void;
onRefresh?: () => void;
onSendToChat?: (node: TreeNode<T>) => void;
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
allowCreateFolder?: boolean;
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,339 @@
/* ---------------------------------------------------------------------------
GroupFolderRow file-browser-style folder rows in the data table
--------------------------------------------------------------------------- */
.groupFolderRow {
background: var(--color-surface, #eef0f2);
border-bottom: 1px solid var(--color-border, #d4d9e0);
transition: background 0.12s;
user-select: none;
}
.groupFolderRow:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-surface, #eef0f2));
}
.groupFolderRow.dragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 18%, var(--color-surface, #eef0f2));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
/* Drop zone when another GROUP is dragged onto this group */
.groupFolderRow.dragOverGroup {
background: color-mix(in srgb, #d69e2e 18%, var(--color-surface, #eef0f2));
outline: 2px dashed #d69e2e;
outline-offset: -2px;
}
/* Cursor hint while dragging a group row */
.groupFolderRow[draggable="true"] {
cursor: grab;
}
.groupFolderRow[draggable="true"]:active {
cursor: grabbing;
}
/* Visual feedback: group is being dragged leftward to pop out */
.groupFolderRow.draggingOut {
opacity: 0.5;
border-left: 3px solid #d69e2e;
}
/* Folder subtree selection (aligned with tbody .tr.selected) */
.groupFolderRow.folderRowSubtreeFull {
background: rgba(124, 109, 216, 0.08);
background: rgba(var(--color-secondary-rgb), 0.08);
}
.groupFolderRow.folderRowSubtreePartial {
background: rgba(124, 109, 216, 0.04);
background: rgba(var(--color-secondary-rgb), 0.04);
}
.folderCell {
padding: 0 !important;
width: 100%;
}
.separator {
display: inline-block;
width: 1px;
height: 18px;
background: var(--color-border, #d4d9e0);
margin: 0 4px;
flex-shrink: 0;
}
.folderInner {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px 5px 0;
min-height: 34px;
width: 100%;
box-sizing: border-box;
}
.indent {
display: inline-block;
flex-shrink: 0;
}
/* Expand/collapse chevron button */
.chevronBtn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
flex-shrink: 0;
border-radius: 3px;
width: 20px;
height: 20px;
}
.chevronBtn:hover {
background: var(--color-primary-light, rgba(74,111,165,0.12));
}
/* Pure-CSS triangle arrow */
.chevronArrow {
display: inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid var(--color-text-secondary, #64748b);
transition: transform 0.15s;
flex-shrink: 0;
}
.chevronBtn:hover .chevronArrow {
border-left-color: var(--color-primary, #4a6fa5);
}
.chevronOpen .chevronArrow {
transform: rotate(90deg);
}
/* Folder icon (SVG via react-icons) */
.folderIcon {
font-size: 14px;
flex-shrink: 0;
line-height: 1;
margin-right: 2px;
color: var(--color-primary, #4a6fa5);
display: inline-flex;
align-items: center;
}
/* Group name text */
.groupName {
font-size: 13px;
font-weight: 500;
color: var(--color-text, #2d3748);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 300px;
}
.unnamed {
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
font-weight: 400;
}
/* Inline name input when editing */
.nameInput {
font-size: 13px;
font-weight: 500;
border: 1px solid var(--color-primary, #4a6fa5);
border-radius: 4px;
padding: 2px 8px;
outline: none;
background: var(--color-bg, #fff);
color: var(--color-text, #2d3748);
min-width: 160px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 20%, transparent);
}
/* Item count badge */
.badge {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 15%, transparent);
color: var(--color-primary, #4a6fa5);
border-radius: 10px;
padding: 0 7px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
flex-shrink: 0;
margin-left: 4px;
}
/* Drop hint text */
.dropHint {
font-size: 11px;
font-style: italic;
color: var(--color-primary, #4a6fa5);
margin-left: 4px;
animation: pulse 1s ease-in-out infinite alternate;
}
@keyframes pulse {
from { opacity: 0.6; }
to { opacity: 1.0; }
}
/* ── Bulk item action buttons (same type as per-row action buttons) ── */
.actions {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-right: 4px;
}
.actionBtn {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d9e0);
cursor: pointer;
padding: 3px 8px;
border-radius: 5px;
font-size: 12px;
color: var(--color-text-secondary, #64748b);
transition: background 0.1s, color 0.1s, border-color 0.1s;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
}
.actionBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actionBtn:not(:disabled):hover {
background: var(--color-surface, #eef0f2);
color: var(--color-text, #2d3748);
border-color: var(--color-primary, #4a6fa5);
}
.actionBtnDanger:not(:disabled):hover {
background: color-mix(in srgb, #e53e3e 10%, transparent);
color: #c53030;
border-color: #c53030;
}
/* ── Group management buttons (rename / add-sub / delete-group) ── */
.mgmtActions {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
border-left: 1px solid var(--color-border, #d4d9e0);
padding-left: 6px;
margin-left: 2px;
}
.mgmtBtn {
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 3px;
font-size: 11px;
color: var(--color-text-secondary, #94a3b8);
transition: background 0.1s, color 0.1s;
display: inline-flex;
align-items: center;
height: 22px;
}
.mgmtBtn:hover {
background: var(--color-border, #d4d9e0);
color: var(--color-text, #2d3748);
}
.mgmtBtnDanger:hover {
background: color-mix(in srgb, #e53e3e 12%, transparent);
color: #c53030;
}
/* ---------------------------------------------------------------------------
Breadcrumb row
--------------------------------------------------------------------------- */
.breadcrumbRow {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 6%, var(--color-bg, #fff));
}
.breadcrumbCell {
padding: 8px 14px !important;
}
.breadcrumbInner {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.backButton {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #4a6fa5);
font-size: 13px;
padding: 2px 8px;
border-radius: 5px;
transition: background 0.1s;
}
.backButton:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 12%, transparent);
}
.breadcrumbSep {
color: var(--color-text-secondary, #94a3b8);
}
.breadcrumbCurrent {
font-weight: 600;
color: var(--color-text, #2d3748);
}
/* ---------------------------------------------------------------------------
Ungrouped section row
--------------------------------------------------------------------------- */
.ungroupedRow {
background: var(--color-bg, #f8f9fa);
transition: background 0.12s, outline 0.12s;
}
/* Drop target: item or group dragged back to root */
.ungroupedDragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 10%, var(--color-bg, #f8f9fa));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
.ungroupedCell {
display: flex !important;
align-items: center;
gap: 6px;
padding: 5px 14px !important;
font-size: 12px;
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
border-top: 1px dashed var(--color-border, #d4d9e0);
}

View file

@ -0,0 +1,379 @@
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useConfirm } from '../../../hooks/useConfirm';
import styles from './GroupRow.module.css';
import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css';
/** Legacy folder-tree row model (client-side group tree); kept for GroupFolderRow typings. */
export interface TableGroupNode {
name: string;
itemIds: string[];
}
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GroupBulkAction {
icon?: React.ReactNode;
title?: string;
variant?: 'default' | 'danger';
onClick: () => void;
disabled?: boolean;
}
/** Horizontal shift per nesting level — keep in sync with item rows (`FormGeneratorTable`). */
export const GROUP_TREE_INDENT_STEP_PX = 20;
// ---------------------------------------------------------------------------
// GroupFolderRow
// ---------------------------------------------------------------------------
/** Folder row: optional select column, then one merged cell for folder UI (spans actions + data cols — no blank actions column). */
export interface GroupFolderTableCells {
showSelect: boolean;
/** `<td colSpan>` for folder strip = `detectedColumns.length` + (1 if table has an actions column). */
dataColumnsCount: number;
selectClassName: string;
selectTdStyle?: React.CSSProperties;
}
interface GroupFolderRowProps {
node: TableGroupNode;
depth: number;
/** Checkbox for “whole subtree”: select / clear all selectable visible items under this folder. */
subtreeSelect?: {
checked: boolean;
indeterminate: boolean;
disabled: boolean;
onToggle: () => void;
};
/** When set, use split `<td>` layout; omit single-cell colspan. */
tableCells?: GroupFolderTableCells;
/** Legacy single spanning cell — only used when `tableCells` is omitted. */
colSpan?: number;
visibleCount: number;
isExpanded: boolean;
isEditing: boolean;
/** True while an ITEM is dragged over this row (drop item into group). */
isDragOver: boolean;
/** True while a GROUP is dragged over this row (nest group inside). */
isDragOverFromGroup: boolean;
bulkActions?: GroupBulkAction[];
onToggle: () => void;
onEditCommit: (name: string) => void;
onEditCancel: () => void;
onRename: () => void;
onAddSub: () => void;
// Item drag-drop
onItemDragOver: (e: React.DragEvent) => void;
onItemDrop: (e: React.DragEvent) => void;
onItemDragLeave: () => void;
// Group drag (this row is draggable)
onGroupDragStart: (e: React.DragEvent) => void;
onGroupDragEnd: () => void;
onGroupDrag?: (e: React.DragEvent) => void;
/** True while this group is being dragged leftward to pop out one level */
isDraggingOut?: boolean;
/** Hide this row via display:none (keeps it in DOM so drag operations don't break) */
hidden?: boolean;
// Group drop (another group dropped onto this)
onGroupDragOver: (e: React.DragEvent) => void;
onGroupDrop: (e: React.DragEvent) => void;
onGroupDragLeave: () => void;
}
export function GroupFolderRow({
node,
depth,
subtreeSelect,
tableCells,
colSpan,
visibleCount,
isExpanded,
isEditing,
isDragOver,
isDragOverFromGroup,
isDraggingOut,
hidden,
bulkActions = [],
onToggle,
onEditCommit,
onEditCancel,
onRename,
onAddSub,
onItemDragOver,
onItemDrop,
onItemDragLeave,
onGroupDragStart,
onGroupDragEnd,
onGroupDrag,
onGroupDragOver,
onGroupDrop,
onGroupDragLeave,
}: GroupFolderRowProps) {
const { t } = useLanguage();
const { ConfirmDialog } = useConfirm();
const inputRef = useRef<HTMLInputElement>(null);
const subtreeCbRef = useRef<HTMLInputElement>(null);
const totalCount = node.itemIds.length;
useEffect(() => {
const el = subtreeCbRef.current;
if (!el || !subtreeSelect) return;
el.indeterminate = subtreeSelect.indeterminate;
}, [subtreeSelect?.indeterminate, subtreeSelect?.checked, subtreeSelect]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const indentPx = depth * GROUP_TREE_INDENT_STEP_PX;
const _rowClass = [
styles.groupFolderRow,
tableCells ? fgTableCss.treeRowIndented : '',
isDragOver ? styles.dragOver : '',
isDragOverFromGroup ? styles.dragOverGroup : '',
isDraggingOut ? styles.draggingOut : '',
subtreeSelect?.checked && !subtreeSelect?.disabled ? styles.folderRowSubtreeFull : '',
subtreeSelect?.indeterminate && !subtreeSelect?.checked ? styles.folderRowSubtreePartial : '',
].filter(Boolean).join(' ');
const mergedColSpan =
tableCells
? tableCells.dataColumnsCount
: (colSpan ?? 1);
const folderStripStyle =
({
'--group-indent': `${indentPx}px`,
...(tableCells
? { ['--row-tree-indent' as string]: `${depth * GROUP_TREE_INDENT_STEP_PX}px` }
: {}),
}) as React.CSSProperties;
const guardDragDecor = (
e: React.DragEvent,
relay: React.DragEventHandler | undefined,
) => {
const el = e.target as HTMLElement;
if (el.closest('input, button, textarea, label')) {
e.preventDefault();
e.stopPropagation();
return;
}
relay?.(e);
};
const folderCells = (
<>
{typeof document !== 'undefined' && ReactDOM.createPortal(<ConfirmDialog />, document.body)}
<tr
className={_rowClass}
style={{ ...folderStripStyle, display: hidden ? 'none' : undefined } as React.CSSProperties}
draggable={!isEditing}
onDragStart={(e) => guardDragDecor(e, onGroupDragStart)}
onDrag={(e) => guardDragDecor(e, onGroupDrag)}
onDragEnd={(e) => guardDragDecor(e, onGroupDragEnd)}
// item drag-over
onDragOver={(e) => {
// distinguish item vs group drag via dataTransfer type
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDragOver(e);
} else {
onItemDragOver(e);
}
}}
onDrop={(e) => {
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDrop(e);
} else {
onItemDrop(e);
}
}}
onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }}
onDragEnter={(e) => e.preventDefault()}
>
{tableCells?.showSelect && (
<td className={tableCells.selectClassName} style={tableCells.selectTdStyle}>
{subtreeSelect && (
<input
ref={subtreeCbRef}
type="checkbox"
checked={subtreeSelect.checked}
disabled={subtreeSelect.disabled}
onChange={(e) => { e.stopPropagation(); subtreeSelect.onToggle(); }}
onClick={(e) => e.stopPropagation()}
title={node.name ? t('Auswahl unter „{name}“', { name: node.name }) : t('Auswahl dieser Gruppe')}
aria-label={node.name ? t('Alle sichtbaren Einträge in „{name}“ auswählen', { name: node.name }) : t('Alle sichtbaren Einträge in dieser Gruppe auswählen')}
/>
)}
</td>
)}
<td colSpan={tableCells ? mergedColSpan : (colSpan ?? 1)} className={styles.folderCell}>
<div className={styles.folderInner}>
{/* Indent */}
{indentPx > 0 && <span className={styles.indent} style={{ width: indentPx }} />}
{/* Chevron */}
<button
className={`${styles.chevronBtn} ${isExpanded ? styles.chevronOpen : ''}`}
type="button"
onClick={(e) => { e.stopPropagation(); onToggle(); }}
title={isExpanded ? t('Zuklappen') : t('Aufklappen')}
tabIndex={-1}
>
<span className={styles.chevronArrow} />
</button>
{/* Folder icon */}
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{/* Name / inline input */}
{isEditing ? (
<input
ref={inputRef}
defaultValue={node.name}
className={styles.nameInput}
placeholder={t('Gruppenname…')}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditCommit(e.currentTarget.value);
if (e.key === 'Escape') onEditCancel();
}}
onBlur={(e) => onEditCommit(e.target.value)}
/>
) : (
<span className={styles.groupName} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
{node.name || <em className={styles.unnamed}>{t('(Unbenannt)')}</em>}
</span>
)}
{/* Item count badge */}
{!isEditing && (
<span className={styles.badge}>
{visibleCount < totalCount && totalCount > 0
? `${visibleCount} / ${totalCount}`
: String(totalCount)}
</span>
)}
{/* Drop hint */}
{(isDragOver || isDragOverFromGroup) && (
<span className={styles.dropHint}>
{isDragOverFromGroup ? t('Als Untergruppe ablegen') : t('Hierher ziehen')}
</span>
)}
{/* ── Bulk actions (delete all, custom batch) right after badge ── */}
{!isEditing && bulkActions.length > 0 && (
<>
<span className={styles.separator} />
<span className={styles.actions}>
{bulkActions.map((action, i) => (
<button
key={i}
type="button"
className={`${styles.actionBtn} ${action.variant === 'danger' ? styles.actionBtnDanger : ''}`}
title={action.title}
disabled={!!action.disabled}
onClick={(e) => { e.stopPropagation(); if (!action.disabled) action.onClick(); }}
>
{action.icon}
</button>
))}
</span>
</>
)}
{/* ── Group management: rename / add-subgroup ── */}
{!isEditing && (
<span className={styles.mgmtActions}>
<button type="button" onClick={(e) => { e.stopPropagation(); onRename(); }} title={t('Umbenennen')} className={styles.mgmtBtn}><FaPen /></button>
<button type="button" onClick={(e) => { e.stopPropagation(); onAddSub(); }} title={t('Untergruppe erstellen')} className={styles.mgmtBtn}><FaPlus /></button>
</span>
)}
<span style={{ flex: 1 }} />
</div>
</td>
</tr>
</>
);
return folderCells;
}
// ---------------------------------------------------------------------------
// BreadcrumbRow
// ---------------------------------------------------------------------------
interface BreadcrumbRowProps {
groupName: string;
totalItems: number;
colSpan: number;
onBack: () => void;
}
export function BreadcrumbRow({ groupName, totalItems, colSpan, onBack }: BreadcrumbRowProps) {
const { t } = useLanguage();
return (
<tr className={styles.breadcrumbRow}>
<td colSpan={colSpan} className={styles.breadcrumbCell}>
<div className={styles.breadcrumbInner}>
<button className={styles.backButton} onClick={onBack}>
{t('Alle anzeigen')}
</button>
<span className={styles.breadcrumbSep}></span>
<span className={styles.breadcrumbCurrent}>{groupName}</span>
{totalItems > 0 && (
<span style={{ color: 'var(--color-text-secondary, #94a3b8)', fontSize: '11px' }}>
({totalItems} {t('Einträge')})
</span>
)}
</div>
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// UngroupedRow — also a drop zone for removing items/groups from groups
// ---------------------------------------------------------------------------
interface UngroupedRowProps {
count: number;
colSpan: number;
isDragOver?: boolean;
onDragOver?: (e: React.DragEvent) => void;
onDrop?: (e: React.DragEvent) => void;
onDragLeave?: () => void;
}
export function UngroupedRow({ count, colSpan, isDragOver, onDragOver, onDrop, onDragLeave }: UngroupedRowProps) {
const { t } = useLanguage();
return (
<tr
className={`${styles.ungroupedRow} ${isDragOver ? styles.ungroupedDragOver : ''}`}
onDragOver={onDragOver}
onDrop={onDrop}
onDragLeave={onDragLeave}
onDragEnter={(e) => e.preventDefault()}
>
<td colSpan={colSpan} className={styles.ungroupedCell}>
<span className={styles.folderIcon}><FaList /></span>
{t('Nicht zugeordnet')}
<span className={styles.badge}>{count}</span>
{isDragOver && <span className={styles.dropHint}>{t('Aus Gruppe entfernen')}</span>}
</td>
</tr>
);
}

View file

@ -0,0 +1,286 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 8px 0 12px;
border-bottom: 1px solid var(--color-border, #e2e8f0);
margin-bottom: 8px;
}
.popoverAnchor {
position: relative;
}
.groupTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.groupIcon {
display: block;
font-size: 16px;
opacity: 0.9;
}
.groupTrigger:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
border-color: var(--color-primary, #64748b);
}
.groupTriggerOpen {
border-color: var(--color-primary, #4a6fa5);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 25%, transparent);
}
.popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 4200;
min-width: min(360px, calc(100vw - 24px));
padding: 14px 14px 12px;
border-radius: 12px;
border: 1px solid var(--color-border, #e2e8f0);
background: var(--color-bg, #ffffff);
color: var(--color-text, #0f172a);
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.12);
}
.popoverTitle {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary, #94a3b8);
margin: 0 0 6px;
}
.popoverHint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted, #64748b);
}
.levelList {
display: flex;
flex-direction: column;
gap: 8px;
}
.levelRow {
display: grid;
grid-template-columns: 1fr 118px 36px;
gap: 8px;
align-items: center;
}
.select,
.selectOrder {
padding: 8px 10px;
font-size: 13px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.select:disabled,
.selectOrder:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary, #94a3b8);
cursor: pointer;
}
.iconBtn:hover:not(:disabled) {
color: #fecaca;
background: rgba(239, 68, 68, 0.12);
}
.iconBtn:disabled {
opacity: 0.25;
cursor: not-allowed;
}
.addLevelBtn {
margin-top: 12px;
width: 100%;
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
border: 1px dashed var(--color-border, #475569);
background: transparent;
color: var(--text-secondary, #94a3b8);
cursor: pointer;
}
.addLevelBtn:hover {
border-color: var(--color-primary, #4a6fa5);
color: var(--color-primary, #7dd3fc);
}
.activeSummary {
font-size: 12px;
color: var(--text-secondary, #64748b);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewBlock {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-left: auto;
}
.viewLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary, #64748b);
}
.viewSelect {
min-width: 160px;
padding: 6px 10px;
font-size: 13px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
}
.btnGhost {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: transparent;
color: var(--color-text, #334155);
cursor: pointer;
}
.btnGhost:hover {
background: var(--bg-hover, #f1f5f9);
}
.btnDangerGhost {
padding: 6px 12px;
font-size: 12px;
border-radius: 8px;
border: 1px solid #fecaca;
background: transparent;
color: #b91c1c;
cursor: pointer;
}
.btnDangerGhost:hover {
background: #fef2f2;
}
.btnPrimary {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 8px;
border: none;
background: var(--color-primary, #4a6fa5);
color: #fff;
cursor: pointer;
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modalBackdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
z-index: 4500;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal {
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
border-radius: 12px;
padding: 20px 22px;
max-width: 420px;
width: 100%;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
.modal h3 {
margin: 0 0 8px;
font-size: 17px;
}
.modalHint {
margin: 0 0 14px;
font-size: 13px;
color: var(--text-secondary, #64748b);
line-height: 1.45;
}
.modalField {
margin-bottom: 12px;
}
.modalField label {
display: block;
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-secondary, #64748b);
}
.modalField input {
width: 100%;
padding: 8px 10px;
font-size: 14px;
border: 1px solid var(--color-border, #cbd5e1);
border-radius: 8px;
box-sizing: border-box;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}

View file

@ -0,0 +1,337 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './TableViewsBar.module.css';
export interface TableViewOption {
id: string;
viewKey: string;
displayName: string;
}
/** One grouping level (ClickUp-style): column + band order for that level. */
export interface GroupByLevelSpec {
field: string;
direction: 'asc' | 'desc';
}
export interface TableViewsBarProps {
views: TableViewOption[];
loadingViews: boolean;
activeViewKey: string | null;
activeViewId: string | null;
groupByLevels: GroupByLevelSpec[];
onGroupByLevelsChange: (levels: GroupByLevelSpec[]) => void;
onSelectView: (viewKey: string | null) => void;
columnOptions: Array<{ key: string; label: string }>;
onCreateView: (displayName: string, viewKey: string) => void | Promise<void>;
/** When a saved view is active, overwrite its config (filters, sort, grouping, folds). Optional. */
onSaveActiveView?: () => void | Promise<void>;
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
onDeleteView?: (viewId: string) => void | Promise<void>;
onReloadViews: () => void;
}
function slugify(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-_]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'view';
}
export function groupLevelsToApiPayload(levels: GroupByLevelSpec[]) {
return levels
.filter((l) => l.field)
.map((l) => ({ field: l.field, nullLabel: '—', direction: l.direction }));
}
function commitLevels(
next: GroupByLevelSpec[],
activeViewId: string | null,
onGroupByLevelsChange: (l: GroupByLevelSpec[]) => void,
onUpdateViewGrouping: (id: string, l: GroupByLevelSpec[]) => void | Promise<void>,
) {
onGroupByLevelsChange(next);
if (activeViewId) {
void Promise.resolve(onUpdateViewGrouping(activeViewId, next));
}
}
export function TableViewsBar({
views,
loadingViews,
activeViewKey,
activeViewId,
groupByLevels,
onGroupByLevelsChange,
onSelectView,
columnOptions,
onCreateView,
onSaveActiveView,
onUpdateViewGrouping,
onDeleteView,
onReloadViews,
}: TableViewsBarProps) {
const { t } = useLanguage();
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const [saveOpen, setSaveOpen] = useState(false);
const [newName, setNewName] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!groupMenuOpen) return;
const onDoc = (e: MouseEvent) => {
const el = wrapRef.current;
if (el && !el.contains(e.target as Node)) setGroupMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setGroupMenuOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [groupMenuOpen]);
const levelsForUi = useMemo(
() => (groupByLevels.length > 0 ? groupByLevels : [{ field: '', direction: 'asc' as const }]),
[groupByLevels],
);
const usedFields = useMemo(
() => new Set(groupByLevels.map((l) => l.field).filter(Boolean)),
[groupByLevels],
);
const columnsForRow = useCallback(
(_rowIdx: number, currentField: string) =>
columnOptions.filter((c) => c.key === currentField || !usedFields.has(c.key) || !c.key),
[columnOptions, usedFields],
);
const [overwriteSaving, setOverwriteSaving] = useState(false);
const _onClickSave = useCallback(async () => {
if (activeViewId && onSaveActiveView) {
setOverwriteSaving(true);
try {
await onSaveActiveView();
await onReloadViews();
} catch (e) {
console.error('Save active view failed', e);
} finally {
setOverwriteSaving(false);
}
return;
}
setSaveOpen(true);
setNewName('');
}, [activeViewId, onSaveActiveView, onReloadViews]);
const _saveNew = async () => {
const name = newName.trim();
const slug = slugify(name);
if (!name || !slug) return;
setSaving(true);
try {
await onCreateView(name, slug);
setSaveOpen(false);
setNewName('');
await onReloadViews();
} finally {
setSaving(false);
}
};
const updateLevel = (idx: number, patch: Partial<GroupByLevelSpec>) => {
const working = levelsForUi.map((l, i) => (i === idx ? { ...l, ...patch } : l));
const normalized = working.filter((l) => l.field);
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
};
const addLevelRow = () => {
commitLevels(
[...groupByLevels, { field: '', direction: 'asc' }],
activeViewId,
onGroupByLevelsChange,
onUpdateViewGrouping,
);
};
const removeLevel = (idx: number) => {
const working = levelsForUi.filter((_, i) => i !== idx);
const normalized = working.filter((l) => l.field);
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
};
const summary =
groupByLevels.length === 0
? t('Keine')
: groupByLevels
.filter((l) => l.field)
.map((l) => columnOptions.find((c) => c.key === l.field)?.label ?? l.field)
.join(' ');
return (
<div className={styles.toolbar}>
<div ref={wrapRef} className={styles.popoverAnchor}>
<button
type="button"
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
onClick={() => setGroupMenuOpen((o) => !o)}
aria-expanded={groupMenuOpen}
aria-label={t('Gruppieren')}
title={t('Gruppieren')}
>
<FaLayerGroup className={styles.groupIcon} aria-hidden />
</button>
{groupMenuOpen && (
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
<div className={styles.levelList}>
{levelsForUi.map((level, idx) => (
<div key={idx} className={styles.levelRow}>
<select
className={styles.select}
aria-label={t('Spalte')}
value={level.field}
onChange={(e) => updateLevel(idx, { field: e.target.value })}
>
<option value="">{t('Spalte wählen')}</option>
{columnsForRow(idx, level.field).map((c) => (
<option key={c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</select>
<select
className={styles.selectOrder}
aria-label={t('Sortierung')}
value={level.direction}
disabled={!level.field}
onChange={(e) =>
updateLevel(idx, { direction: e.target.value === 'desc' ? 'desc' : 'asc' })
}
>
<option value="asc">{t('Aufsteigend')}</option>
<option value="desc">{t('Absteigend')}</option>
</select>
<button
type="button"
className={styles.iconBtn}
title={t('Ebene entfernen')}
aria-label={t('Ebene entfernen')}
disabled={levelsForUi.length <= 1 && !level.field}
onClick={() => removeLevel(idx)}
>
<FaTrash />
</button>
</div>
))}
</div>
<button type="button" className={styles.addLevelBtn} onClick={addLevelRow}>
{t('+ Weitere Ebene')}
</button>
</div>
)}
</div>
<span className={styles.activeSummary} title={summary}>
{groupByLevels.filter((l) => l.field).length === 0
? t('Nicht gruppiert')
: `${t('Aktiv')}: ${summary}`}
</span>
<div className={styles.viewBlock}>
<span className={styles.viewLabel}>{t('Ansicht')}</span>
<select
className={styles.viewSelect}
value={activeViewKey ?? ''}
disabled={loadingViews}
onChange={(e) => {
const v = e.target.value;
onSelectView(v === '' ? null : v);
}}
>
<option value="">{t('Standard')}</option>
{views.map((v) => (
<option key={v.id} value={v.viewKey}>
{v.displayName}
</option>
))}
</select>
<button
type="button"
className={styles.btnGhost}
disabled={loadingViews || overwriteSaving}
title={
activeViewId
? t('Aktuelle Ansicht mit Filter, Sortierung und Gruppierung überschreiben')
: t('Neue Ansicht speichern')
}
onClick={() => void _onClickSave()}
>
{overwriteSaving ? t('Wird gespeichert…') : t('Speichern…')}
</button>
{activeViewId && onDeleteView && (
<button
type="button"
className={styles.btnDangerGhost}
onClick={() => {
if (window.confirm(t('Diese Ansicht wirklich löschen?'))) {
void Promise.resolve(onDeleteView(activeViewId)).then(() => onReloadViews());
}
}}
>
{t('Löschen')}
</button>
)}
</div>
{saveOpen && (
<div
className={styles.modalBackdrop}
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setSaveOpen(false);
}}
>
<div className={styles.modal} role="dialog" aria-labelledby="new-view-title" onClick={(e) => e.stopPropagation()}>
<h3 id="new-view-title">{t('Neue Ansicht')}</h3>
<p className={styles.modalHint}>{t('Übernimmt Filter, Sortierung und Gruppierung.')}</p>
<div className={styles.modalField}>
<label htmlFor="nv-name">{t('Anzeigename')}</label>
<input
id="nv-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('z. B. Nach Status')}
autoFocus
/>
</div>
<div className={styles.modalActions}>
<button type="button" className={styles.btnGhost} onClick={() => setSaveOpen(false)}>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.btnPrimary}
disabled={saving || !newName.trim()}
onClick={() => void _saveNew()}
>
{saving ? t('Speichern…') : t('Erstellen')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1 @@
export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar';

View file

@ -4,6 +4,7 @@ export * from './FormGeneratorList';
export * from './FormGeneratorForm';
export * from './FormGeneratorControls';
export * from './FormGeneratorReport';
export * from './FormGeneratorTree';
// Alias FormGeneratorTable as FormGenerator for backward compatibility
export { FormGeneratorTable as FormGenerator, FormGeneratorTableComponent as FormGeneratorComponent } from './FormGeneratorTable';

View file

@ -10,7 +10,7 @@
* - NavLink integration with React Router
*/
import React, { useState, useEffect, ReactNode } from 'react';
import React, { useState, useEffect, useRef, useCallback, ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import styles from './TreeNavigation.module.css';
@ -151,6 +151,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const [isExpanded, setIsExpanded] = useState(
node.defaultExpanded ?? shouldAutoExpand ?? false
);
const containerRef = useRef<HTMLDivElement>(null);
// Auto-expand when path becomes active
useEffect(() => {
@ -159,6 +160,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
}, [currentPath, autoExpandActive, node]);
const _scrollAfterExpand = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const viewportMid = window.innerHeight / 2;
if (rect.top > viewportMid) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}, []);
// Check if this node is active (exact match or ancestor of active path)
const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false;
// Differentiate: leaf active (strong highlight) vs group active (subtle text only)
@ -179,12 +190,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
if (isExpandable && !node.path) {
// If only expandable (no path), toggle expand
setIsExpanded(!isExpanded);
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) setTimeout(_scrollAfterExpand, 50);
} else if (isExpandable && node.path) {
// If both expandable and has path, expand on click but allow navigation
if (!isExpanded) {
setIsExpanded(true);
setTimeout(_scrollAfterExpand, 50);
}
}
@ -197,7 +209,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const handleToggleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) setTimeout(_scrollAfterExpand, 50);
};
// Render the node content (actions are rendered outside to avoid button-in-button nesting)
@ -255,7 +269,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const canRenderChildren = maxDepth === 0 || level < maxDepth;
return (
<div className={styles.treeNodeContainer}>
<div className={styles.treeNodeContainer} ref={containerRef}>
{nodeElement}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>

View file

@ -0,0 +1,394 @@
/* PeriodPicker - styled with global theme variables (Light/Dark via :root). */
.wrapper {
position: relative;
display: inline-block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-medium, 8px);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
min-width: 280px;
justify-content: space-between;
transition: border-color 0.15s ease;
}
.trigger:hover:not(:disabled) {
border-color: var(--primary-color, #4A6FA5);
}
.trigger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.trigger.open {
border-color: var(--primary-color, #4A6FA5);
}
.triggerIcon {
font-size: 1rem;
color: var(--text-secondary, #4A5568);
}
.triggerText {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.triggerChev {
color: var(--text-tertiary, #718096);
font-size: 0.7rem;
}
/* ---------- Popover ---------- */
.popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1000;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1A202C);
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-large, 10px);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
width: 720px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 120px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.popover.alignRight {
left: auto;
right: 0;
}
.body {
display: grid;
grid-template-columns: 200px 240px 1fr;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
/* ---------- Column 1: presets ---------- */
.colPresets {
background: var(--bg-secondary, #F7FAFC);
padding: 0.625rem 0.5rem;
border-right: 1px solid var(--border-color, #E2E8F0);
display: flex;
flex-direction: column;
gap: 2px;
}
.presetBtn {
text-align: left;
padding: 0.5rem 0.75rem;
background: transparent;
color: var(--text-primary, #1A202C);
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.presetBtn:hover:not(:disabled) {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
}
.presetBtn.active {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.presetBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Column 2: last/next N ---------- */
.colLastN {
padding: 0.875rem 1rem;
border-right: 1px solid var(--border-color, #E2E8F0);
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.colTitle {
margin: 0;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary, #718096);
font-weight: 600;
}
.lastNRow {
display: flex;
gap: 0.375rem;
align-items: center;
flex-wrap: wrap;
}
.seg {
display: inline-flex;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
overflow: hidden;
}
.segBtn {
padding: 0.375rem 0.625rem;
background: var(--bg-input, #ffffff);
border: none;
cursor: pointer;
font: inherit;
font-size: 0.75rem;
color: var(--text-secondary, #4A5568);
}
.segBtn.on {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.segBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.numInput {
width: 64px;
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.unitSelect {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.applyN {
margin-top: 4px;
padding: 0.375rem 0.625rem;
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
color: var(--primary-color, #4A6FA5);
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-weight: 600;
align-self: flex-start;
}
.applyN:hover:not(:disabled) {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.applyN:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Column 3: calendar ---------- */
.colCalendar {
padding: 0.75rem 0.875rem;
display: flex;
flex-direction: column;
min-width: 0;
}
.calNav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.calNavBtn {
background: transparent;
border: 1px solid transparent;
padding: 0.25rem 0.5rem;
cursor: pointer;
border-radius: var(--object-radius-small, 4px);
font-size: 0.875rem;
color: var(--text-secondary, #4A5568);
}
.calNavBtn:hover {
background: var(--bg-secondary, #F7FAFC);
color: var(--text-primary, #1A202C);
}
.calTitle {
font-size: 0.8125rem;
color: var(--text-secondary, #4A5568);
}
.calMonths {
display: flex;
flex-direction: column;
gap: 1rem;
}
.calMonth h5 {
margin: 0 0 0.375rem;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1A202C);
}
.calGrid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.dowCell {
color: var(--text-tertiary, #718096);
text-align: center;
font-size: 0.625rem;
padding: 0.25rem 0;
text-transform: uppercase;
}
.dayCell {
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: var(--object-radius-small, 4px);
user-select: none;
color: var(--text-primary, #1A202C);
font-size: 0.75rem;
border: 1px solid transparent;
background: transparent;
font-family: inherit;
padding: 0;
}
.dayCell.muted {
color: var(--text-tertiary, #718096);
opacity: 0.55;
}
.dayCell.disabled {
color: var(--color-gray-disabled, #CBD5E0);
cursor: not-allowed;
text-decoration: line-through;
}
.dayCell:not(.disabled):hover {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
}
.dayCell.inRange {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
border-radius: 0;
}
.dayCell.rangeStart {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dayCell.rangeEnd {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.dayCell.rangeStart.rangeEnd {
border-radius: var(--object-radius-small, 4px);
}
.dayCell.today {
font-weight: 700;
outline: 1px dashed var(--primary-color, #4A6FA5);
outline-offset: -2px;
}
/* ---------- Footer ---------- */
.footer {
border-top: 1px solid var(--border-color, #E2E8F0);
padding: 0.75rem 1rem;
display: flex;
gap: 0.625rem;
align-items: center;
background: var(--bg-secondary, #F7FAFC);
flex-wrap: wrap;
}
.footerLabel {
font-size: 0.75rem;
color: var(--text-secondary, #4A5568);
margin: 0 0.25rem 0 0;
}
.footerInput {
padding: 0.3125rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.spacer {
flex: 1;
}
.btnGhost {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid var(--border-color, #E2E8F0);
color: var(--text-primary, #1A202C);
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.btnGhost:hover {
background: var(--bg-input, #ffffff);
}
.btnPrimary {
padding: 0.375rem 0.875rem;
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
}
.btnPrimary:hover:not(:disabled) {
background: var(--primary-color-dark, #3D5D8A);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- Mobile (single calendar column) ---------- */
@media (max-width: 600px) {
.popover {
width: calc(100vw - 32px);
}
.body {
grid-template-columns: 1fr;
}
.colPresets,
.colLastN {
border-right: none;
border-bottom: 1px solid var(--border-color, #E2E8F0);
}
.colPresets {
flex-direction: row;
flex-wrap: wrap;
}
.presetBtn {
flex: 1 1 auto;
min-width: 45%;
text-align: center;
}
}

View file

@ -0,0 +1,182 @@
/**
* PeriodPicker - public component (Trigger + Popover).
*
* Carries a semantic value `{ preset, fromDate, toDate }`. Presets are
* re-resolved on every render so dynamic ranges (`ytd`, `last12Months`, )
* stay fresh when the user revisits the page.
*
* Outside-click is detected via `mousedown` (not `click`): inner elements
* are re-rendered on selection and would otherwise be detached from the DOM
* when the click event reaches the document, breaking `closest()`.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerPopover from './PeriodPickerPopover';
import {
formatIsoDateDe,
isPresetDisabled,
isValueAllowed,
resolvePeriod,
} from './PeriodPickerLogic';
import type {
PeriodPickerProps,
PeriodPreset,
PeriodValue,
} from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
// Re-export public types so callers can import everything from one place.
export type { PeriodPickerProps, PeriodPreset, PeriodValue, PeriodDirection, PeriodPresetKind, PeriodUnit } from './PeriodPickerTypes';
export { resolvePeriod, isPresetDisabled, isValueAllowed } from './PeriodPickerLogic';
const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' };
function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string {
if (!value) return placeholder;
// "Alle" intentionally skips the range suffix: the sentinel dates
// (1970-2999) would be noise in the trigger.
if (value.preset.kind === 'allTime') return t('Alle');
const range = `${formatIsoDateDe(value.fromDate)} ${formatIsoDateDe(value.toDate)}`;
switch (value.preset.kind) {
case 'ytd': return `${t('Laufendes Jahr')} · ${range}`;
case 'lastYear': return `${t('Letztes Jahr')} · ${range}`;
case 'nextYear': return `${t('Nächstes Jahr')} · ${range}`;
case 'last12Months': return `${t('Letzte 12 Monate')} · ${range}`;
case 'next12Months': return `${t('Nächste 12 Monate')} · ${range}`;
case 'thisMonth': return `${t('Dieser Monat')} · ${range}`;
case 'lastMonth': return `${t('Letzter Monat')} · ${range}`;
case 'thisQuarter': return `${t('Dieses Quartal')} · ${range}`;
case 'lastQuarter': return `${t('Letztes Quartal')} · ${range}`;
case 'lastN': {
const unitLabel = _unitLabelShort(value.preset.unit, t);
return `${t('Letzte')} ${value.preset.amount} ${unitLabel} · ${range}`;
}
case 'nextN': {
const unitLabel = _unitLabelShort(value.preset.unit, t);
return `${t('Nächste')} ${value.preset.amount} ${unitLabel} · ${range}`;
}
case 'custom':
default:
return range;
}
}
function _unitLabelShort(unit: 'day' | 'week' | 'month' | 'year', t: (k: string) => string): string {
switch (unit) {
case 'day': return t('Tage');
case 'week': return t('Wochen');
case 'month': return t('Monate');
case 'year': return t('Jahre');
}
}
export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
const {
value,
onChange,
direction = 'any',
minDate,
maxDate,
enabledPresets,
defaultPreset = _DEFAULT_PRESET,
placeholder,
disabled = false,
className,
} = props;
const { t } = useLanguage();
const constraints = useMemo(
() => ({ direction, minDate, maxDate, enabledPresets }),
[direction, minDate, maxDate, enabledPresets],
);
// Re-resolve semantic presets on every render so values stay fresh.
const resolvedValue: PeriodValue | null = useMemo(() => {
if (!value) return null;
if (value.preset.kind === 'custom') return value;
const r = resolvePeriod(value.preset, value);
if (r.fromDate === value.fromDate && r.toDate === value.toDate) return value;
return { preset: value.preset, fromDate: r.fromDate, toDate: r.toDate };
}, [value]);
const _resolvedTrigger = useMemo(
() => _formatTriggerLabel(resolvedValue, t, placeholder || t('Zeitraum wählen')),
[resolvedValue, t, placeholder],
);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
// Outside click via mousedown (see file header).
useEffect(() => {
if (!open) return;
const _onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (wrapRef.current && wrapRef.current.contains(target)) return;
setOpen(false);
};
window.addEventListener('mousedown', _onDown);
return () => window.removeEventListener('mousedown', _onDown);
}, [open]);
const _initialDraft: PeriodValue = useMemo(() => {
if (resolvedValue) return resolvedValue;
const preset = isPresetDisabled(defaultPreset.kind, constraints)
? ({ kind: 'custom' } as PeriodPreset)
: defaultPreset;
const r = resolvePeriod(preset);
return { preset, fromDate: r.fromDate, toDate: r.toDate };
}, [resolvedValue, defaultPreset, constraints]);
const _handleApply = useCallback((next: PeriodValue) => {
onChange(next);
setOpen(false);
}, [onChange]);
const _handleCancel = useCallback(() => setOpen(false), []);
// If parent-passed value violates constraints, fall back silently to the
// default preset so the trigger never shows a forbidden range.
useEffect(() => {
if (resolvedValue && !isValueAllowed(resolvedValue, constraints)) {
const fallbackPreset = isPresetDisabled(defaultPreset.kind, constraints)
? ({ kind: 'custom' } as PeriodPreset)
: defaultPreset;
const r = resolvePeriod(fallbackPreset, resolvedValue);
onChange({ preset: fallbackPreset, fromDate: r.fromDate, toDate: r.toDate });
}
}, [resolvedValue, constraints, defaultPreset, onChange]);
const triggerCls = [styles.trigger];
if (open) triggerCls.push(styles.open);
return (
<div ref={wrapRef} className={`${styles.wrapper}${className ? ` ${className}` : ''}`}>
<button
type="button"
className={triggerCls.join(' ')}
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className={styles.triggerIcon} aria-hidden>📅</span>
<span className={styles.triggerText}>{_resolvedTrigger}</span>
<span className={styles.triggerChev} aria-hidden></span>
</button>
{open && (
<PeriodPickerPopover
initialValue={_initialDraft}
constraints={constraints}
onApply={_handleApply}
onCancel={_handleCancel}
/>
)}
</div>
);
};
export default PeriodPicker;

View file

@ -0,0 +1,132 @@
/**
* PeriodPicker - dual-month range calendar (vertically stacked).
*
* Pure presentation; receives a `range` and emits `onPickDate`. Constraint
* checks (min/max/direction) are delegated to `isDateDisabled`.
*/
import React, { useMemo } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import {
addMonthsToDate,
buildMonthCells,
isDateDisabled,
_isSameDay,
} from './PeriodPickerLogic';
import type { PeriodConstraints } from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
interface CalendarRange {
from: Date | null;
to: Date | null;
}
interface PeriodPickerCalendarProps {
anchor: Date;
onAnchorChange: (next: Date) => void;
range: CalendarRange;
onPickDate: (d: Date) => void;
constraints: PeriodConstraints;
}
function _monthLabel(d: Date, t: (k: string) => string): string {
switch (d.getMonth()) {
case 0: return `${t('Januar')} ${d.getFullYear()}`;
case 1: return `${t('Februar')} ${d.getFullYear()}`;
case 2: return `${t('März')} ${d.getFullYear()}`;
case 3: return `${t('April')} ${d.getFullYear()}`;
case 4: return `${t('Mai')} ${d.getFullYear()}`;
case 5: return `${t('Juni')} ${d.getFullYear()}`;
case 6: return `${t('Juli')} ${d.getFullYear()}`;
case 7: return `${t('August')} ${d.getFullYear()}`;
case 8: return `${t('September')} ${d.getFullYear()}`;
case 9: return `${t('Oktober')} ${d.getFullYear()}`;
case 10: return `${t('November')} ${d.getFullYear()}`;
case 11: return `${t('Dezember')} ${d.getFullYear()}`;
default: return `${d.getFullYear()}`;
}
}
function _dayOfWeekLabel(idx: number, t: (k: string) => string): string {
switch (idx) {
case 0: return t('Mo');
case 1: return t('Di');
case 2: return t('Mi');
case 3: return t('Do');
case 4: return t('Fr');
case 5: return t('Sa');
case 6: return t('So');
default: return '';
}
}
const PeriodPickerCalendar: React.FC<PeriodPickerCalendarProps> = (props) => {
const { anchor, onAnchorChange, range, onPickDate, constraints } = props;
const { t } = useLanguage();
const monthsToShow = useMemo(() => [anchor, addMonthsToDate(anchor, 1)], [anchor]);
return (
<div className={styles.colCalendar}>
<div className={styles.calNav}>
<button
type="button"
className={styles.calNavBtn}
onClick={() => onAnchorChange(addMonthsToDate(anchor, -1))}
aria-label={t('Vorheriger Monat')}
>
</button>
<span className={styles.calTitle}>
{`${_monthLabel(monthsToShow[0], t)} ${_monthLabel(monthsToShow[1], t)}`}
</span>
<button
type="button"
className={styles.calNavBtn}
onClick={() => onAnchorChange(addMonthsToDate(anchor, 1))}
aria-label={t('Nächster Monat')}
>
</button>
</div>
<div className={styles.calMonths}>
{monthsToShow.map((monthAnchor) => (
<div key={`${monthAnchor.getFullYear()}-${monthAnchor.getMonth()}`} className={styles.calMonth}>
<h5>{_monthLabel(monthAnchor, t)}</h5>
<div className={styles.calGrid} role="grid">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<div key={`dow-${i}`} className={styles.dowCell}>{_dayOfWeekLabel(i, t)}</div>
))}
{buildMonthCells(monthAnchor).map((cell) => {
const disabled = isDateDisabled(cell.date, constraints);
const cls: string[] = [styles.dayCell];
if (!cell.inMonth) cls.push(styles.muted);
if (disabled) cls.push(styles.disabled);
if (cell.isToday) cls.push(styles.today);
if (range.from && range.to && cell.date >= range.from && cell.date <= range.to) {
cls.push(styles.inRange);
}
if (range.from && _isSameDay(cell.date, range.from)) cls.push(styles.rangeStart);
if (range.to && _isSameDay(cell.date, range.to)) cls.push(styles.rangeEnd);
return (
<button
type="button"
key={cell.iso}
className={cls.join(' ')}
disabled={disabled}
onClick={() => onPickDate(cell.date)}
>
{cell.date.getDate()}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
export default PeriodPickerCalendar;

View file

@ -0,0 +1,266 @@
/**
* PeriodPicker - pure logic helpers.
*
* No React, no DOM. All date math is local-date based (no timezone shifting).
* Use ISO `YYYY-MM-DD` strings as the wire format.
*/
import type {
PeriodConstraints,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
} from './PeriodPickerTypes';
// ---------------------------------------------------------------------------
// Date primitives
// ---------------------------------------------------------------------------
const _pad = (n: number): string => String(n).padStart(2, '0');
export function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${_pad(d.getMonth() + 1)}-${_pad(d.getDate())}`;
}
export function fromIsoDate(s: string | null | undefined): Date | null {
if (!s) return null;
const parts = s.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
return new Date(parts[0], parts[1] - 1, parts[2]);
}
export function daysInRange(fromIso: string, toIso: string): number {
const from = fromIsoDate(fromIso);
const to = fromIsoDate(toIso);
if (!from || !to) return 0;
const ms = to.getTime() - from.getTime();
return Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24)) + 1);
}
export function todayDate(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function _addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function _addMonths(d: Date, n: number): Date {
const r = new Date(d);
r.setMonth(r.getMonth() + n);
return r;
}
function _addYears(d: Date, n: number): Date {
const r = new Date(d);
r.setFullYear(r.getFullYear() + n);
return r;
}
function _startOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), 1); }
function _endOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth() + 1, 0); }
function _startOfYear(d: Date): Date { return new Date(d.getFullYear(), 0, 1); }
function _endOfYear(d: Date): Date { return new Date(d.getFullYear(), 11, 31); }
function _startOfQuarter(d: Date): Date {
return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
}
function _endOfQuarter(d: Date): Date {
const s = _startOfQuarter(d);
return new Date(s.getFullYear(), s.getMonth() + 3, 0);
}
function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date {
switch (unit) {
case 'day': return _addDays(d, amount);
case 'week': return _addDays(d, amount * 7);
case 'month': return _addMonths(d, amount);
case 'year': return _addYears(d, amount);
}
}
// ---------------------------------------------------------------------------
// Preset resolver
// ---------------------------------------------------------------------------
// Sentinel bounds used when the user picked ``Alle`` (no date filter). We keep
// the values *inside* ``PeriodValue`` so downstream code that reads
// ``fromDate``/``toDate`` doesn't break; callers that want to forward "no
// filter" to the backend should check ``preset.kind === 'allTime'`` and drop
// the dates explicitly before building the request.
export const ALL_TIME_FROM = '1970-01-01';
export const ALL_TIME_TO = '2999-12-31';
export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } {
const today = todayDate();
switch (preset.kind) {
case 'allTime':
return { fromDate: ALL_TIME_FROM, toDate: ALL_TIME_TO };
case 'ytd':
return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) };
case 'lastYear': {
const ly = _addYears(today, -1);
return { fromDate: toIsoDate(_startOfYear(ly)), toDate: toIsoDate(_endOfYear(ly)) };
}
case 'nextYear': {
const ny = _addYears(today, 1);
return { fromDate: toIsoDate(_startOfYear(ny)), toDate: toIsoDate(_endOfYear(ny)) };
}
case 'last12Months':
return { fromDate: toIsoDate(_addMonths(today, -12)), toDate: toIsoDate(today) };
case 'next12Months':
return { fromDate: toIsoDate(today), toDate: toIsoDate(_addMonths(today, 12)) };
case 'thisMonth':
return { fromDate: toIsoDate(_startOfMonth(today)), toDate: toIsoDate(_endOfMonth(today)) };
case 'lastMonth': {
const lm = _addMonths(today, -1);
return { fromDate: toIsoDate(_startOfMonth(lm)), toDate: toIsoDate(_endOfMonth(lm)) };
}
case 'thisQuarter':
return { fromDate: toIsoDate(_startOfQuarter(today)), toDate: toIsoDate(_endOfQuarter(today)) };
case 'lastQuarter': {
const lq = _addMonths(_startOfQuarter(today), -3);
return { fromDate: toIsoDate(_startOfQuarter(lq)), toDate: toIsoDate(_endOfQuarter(lq)) };
}
case 'lastN':
return { fromDate: toIsoDate(_shiftBy(today, -preset.amount, preset.unit)), toDate: toIsoDate(today) };
case 'nextN':
return { fromDate: toIsoDate(today), toDate: toIsoDate(_shiftBy(today, preset.amount, preset.unit)) };
case 'custom':
// Custom holds whatever was last picked; rely on the previous value if available,
// otherwise default to a single-day range at today to give the calendar an anchor.
return {
fromDate: prevValue?.fromDate || toIsoDate(today),
toDate: prevValue?.toDate || toIsoDate(today),
};
}
}
// ---------------------------------------------------------------------------
// Constraints
// ---------------------------------------------------------------------------
export function isDateDisabled(d: Date, cfg: PeriodConstraints): boolean {
const min = fromIsoDate(cfg.minDate);
const max = fromIsoDate(cfg.maxDate);
if (min && d < min) return true;
if (max && d > max) return true;
if (cfg.direction === 'past' && d > todayDate()) return true;
if (cfg.direction === 'future' && d < todayDate()) return true;
return false;
}
const _FUTURE_PRESETS: PeriodPresetKind[] = ['nextYear', 'next12Months', 'nextN'];
const _PAST_PRESETS: PeriodPresetKind[] = ['lastYear', 'last12Months', 'lastN', 'lastMonth', 'lastQuarter'];
export function isPresetDisabled(kind: PeriodPresetKind, cfg: PeriodConstraints): boolean {
if (cfg.enabledPresets && !cfg.enabledPresets.includes(kind)) return true;
if (cfg.direction === 'past' && _FUTURE_PRESETS.includes(kind)) return true;
if (cfg.direction === 'future' && _PAST_PRESETS.includes(kind)) return true;
return false;
}
export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints): boolean {
if (!value) return false;
if (isPresetDisabled(value.preset.kind, cfg)) return false;
if (value.preset.kind === 'custom') {
const f = fromIsoDate(value.fromDate);
const tt = fromIsoDate(value.toDate);
if (!f || !tt) return false;
if (isDateDisabled(f, cfg)) return false;
if (isDateDisabled(tt, cfg)) return false;
}
return true;
}
// Clamp an ISO date to the direction/min/max window defined by ``cfg``. Used
// for ``<input type="date">`` ``min``/``max`` attributes so the browser
// refuses invalid years instead of us silently falling back to the default
// preset afterwards.
export function clampIsoDate(_iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined {
const today = toIsoDate(todayDate());
let lo: string | undefined = cfg.minDate;
let hi: string | undefined = cfg.maxDate;
if (cfg.direction === 'past') hi = hi && hi < today ? hi : today;
if (cfg.direction === 'future') lo = lo && lo > today ? lo : today;
if (side === 'min') return lo;
return hi;
}
// ---------------------------------------------------------------------------
// Label formatting
// ---------------------------------------------------------------------------
/**
* Returns the human label for a preset kind. Caller wraps with `t()` because
* `t()` only accepts string literals (no variables).
*/
export function presetLiteralKey(kind: PeriodPresetKind): string {
switch (kind) {
case 'allTime': return 'Alle';
case 'ytd': return 'Laufendes Jahr';
case 'lastYear': return 'Letztes Jahr';
case 'nextYear': return 'Nächstes Jahr';
case 'last12Months': return 'Letzte 12 Monate';
case 'next12Months': return 'Nächste 12 Monate';
case 'thisMonth': return 'Dieser Monat';
case 'lastMonth': return 'Letzter Monat';
case 'thisQuarter': return 'Dieses Quartal';
case 'lastQuarter': return 'Letztes Quartal';
case 'lastN': return 'Letzte N';
case 'nextN': return 'Nächste N';
case 'custom': return 'Benutzerdefiniert';
}
}
export function formatIsoDateDe(iso: string): string {
const d = fromIsoDate(iso);
if (!d) return iso;
return `${_pad(d.getDate())}.${_pad(d.getMonth() + 1)}.${d.getFullYear()}`;
}
// ---------------------------------------------------------------------------
// Calendar grid helper
// ---------------------------------------------------------------------------
export interface CalendarCell {
date: Date;
iso: string;
inMonth: boolean;
isToday: boolean;
}
/**
* Returns 6x7 = 42 cells starting on Monday for the given month anchor.
*/
export function buildMonthCells(anchor: Date): CalendarCell[] {
const start = _startOfMonth(anchor);
const leading = (start.getDay() + 6) % 7; // Monday-first
const today = todayDate();
const cells: CalendarCell[] = [];
for (let i = 0; i < 42; i++) {
const d = _addDays(start, i - leading);
cells.push({
date: d,
iso: toIsoDate(d),
inMonth: d.getMonth() === anchor.getMonth(),
isToday: _isSameDay(d, today),
});
}
return cells;
}
export function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
export function addMonthsToDate(d: Date, n: number): Date {
return _addMonths(d, n);
}
export function startOfMonth(d: Date): Date {
return _startOfMonth(d);
}

View file

@ -0,0 +1,363 @@
/**
* PeriodPicker - popover body (3 columns + footer).
*
* Receives the working `draft` value plus constraints, and delegates the
* actual commit to the parent via `onApply` / `onCancel`.
*/
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerCalendar from './PeriodPickerCalendar';
import {
clampIsoDate,
fromIsoDate,
isPresetDisabled,
presetLiteralKey,
resolvePeriod,
startOfMonth,
toIsoDate,
todayDate,
} from './PeriodPickerLogic';
import type {
PeriodConstraints,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
} from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
const PRESETS_ORDER: PeriodPresetKind[] = [
'allTime',
'ytd',
'lastYear',
'nextYear',
'last12Months',
'next12Months',
'thisMonth',
'lastMonth',
'thisQuarter',
'lastQuarter',
'custom',
];
function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string {
switch (kind) {
case 'allTime': return t('Alle');
case 'ytd': return t('Laufendes Jahr');
case 'lastYear': return t('Letztes Jahr');
case 'nextYear': return t('Nächstes Jahr');
case 'last12Months': return t('Letzte 12 Monate');
case 'next12Months': return t('Nächste 12 Monate');
case 'thisMonth': return t('Dieser Monat');
case 'lastMonth': return t('Letzter Monat');
case 'thisQuarter': return t('Dieses Quartal');
case 'lastQuarter': return t('Letztes Quartal');
case 'lastN': return t('Letzte N');
case 'nextN': return t('Nächste N');
case 'custom': return t('Benutzerdefiniert');
}
// Make TS exhaustive checks happy.
return presetLiteralKey(kind);
}
function _unitLabel(unit: PeriodUnit, t: (k: string) => string): string {
switch (unit) {
case 'day': return t('Tage');
case 'week': return t('Wochen');
case 'month': return t('Monate');
case 'year': return t('Jahre');
}
}
interface PeriodPickerPopoverProps {
initialValue: PeriodValue;
constraints: PeriodConstraints;
onApply: (next: PeriodValue) => void;
onCancel: () => void;
}
interface RangePick {
from: Date | null;
to: Date | null;
}
const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
const { initialValue, constraints, onApply, onCancel } = props;
const { t } = useLanguage();
const [draft, setDraft] = useState<PeriodValue>(initialValue);
const [rangePick, setRangePick] = useState<RangePick>(() => ({
from: fromIsoDate(initialValue.fromDate),
to: fromIsoDate(initialValue.toDate),
}));
const [calAnchor, setCalAnchor] = useState<Date>(() => {
const f = fromIsoDate(initialValue.fromDate) || todayDate();
return startOfMonth(f);
});
// "Letzte N / Nächste N" controls
const [lastNDirection, setLastNDirection] = useState<'last' | 'next'>(
constraints.direction === 'future' ? 'next' : 'last',
);
const [lastNAmount, setLastNAmount] = useState<number>(7);
const [lastNUnit, setLastNUnit] = useState<PeriodUnit>('day');
const _commit = useCallback((value: PeriodValue) => {
onApply(value);
}, [onApply]);
const _selectPreset = useCallback((kind: PeriodPresetKind) => {
if (isPresetDisabled(kind, constraints)) return;
if (kind === 'custom') {
// Switching from ``allTime`` back to custom: don't carry the 1970-2999
// sentinel. Seed with today/today so the user gets a sensible starting
// point and the calendar has a real anchor.
const isFromAllTime = draft.preset.kind === 'allTime';
const seedFrom = isFromAllTime ? toIsoDate(todayDate()) : draft.fromDate;
const seedTo = isFromAllTime ? toIsoDate(todayDate()) : draft.toDate;
const next: PeriodValue = {
preset: { kind: 'custom' },
fromDate: seedFrom,
toDate: seedTo,
};
setDraft(next);
setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) });
const anchor = fromIsoDate(seedFrom);
if (anchor) setCalAnchor(startOfMonth(anchor));
return;
}
const preset: PeriodPreset = { kind } as PeriodPreset;
const resolved = resolvePeriod(preset, draft);
_commit({ preset, fromDate: resolved.fromDate, toDate: resolved.toDate });
}, [constraints, draft, _commit]);
const _applyLastN = useCallback(() => {
const amount = Math.max(1, Math.floor(lastNAmount) || 1);
const preset: PeriodPreset = lastNDirection === 'last'
? { kind: 'lastN', amount, unit: lastNUnit }
: { kind: 'nextN', amount, unit: lastNUnit };
if (isPresetDisabled(preset.kind, constraints)) return;
const resolved = resolvePeriod(preset, draft);
_commit({ preset, fromDate: resolved.fromDate, toDate: resolved.toDate });
}, [lastNDirection, lastNAmount, lastNUnit, draft, constraints, _commit]);
const _onPickDate = useCallback((d: Date) => {
const { from, to } = rangePick;
let next: RangePick;
if (!from || (from && to)) {
next = { from: d, to: null };
} else if (d < from) {
next = { from: d, to: from };
} else {
next = { from, to: d };
}
setRangePick(next);
if (next.from && next.to) {
setDraft({
preset: { kind: 'custom' },
fromDate: toIsoDate(next.from),
toDate: toIsoDate(next.to),
});
}
}, [rangePick]);
const _onFooterFromChange = useCallback((iso: string) => {
// Empty string = user cleared the input; ignore so ``draft`` keeps a valid ISO.
if (!iso) return;
const d = fromIsoDate(iso);
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, fromDate: iso }));
setRangePick((prev) => ({ from: d, to: prev.to }));
// Jump the calendar to the typed month so the user immediately sees the
// selection move. Without this, the calendar stays on the current month
// and it *looks* like the input was ignored.
if (d) setCalAnchor(startOfMonth(d));
}, []);
const _onFooterToChange = useCallback((iso: string) => {
if (!iso) return;
const d = fromIsoDate(iso);
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, toDate: iso }));
setRangePick((prev) => ({ from: prev.from, to: d }));
if (d) setCalAnchor(startOfMonth(d));
}, []);
// ``min``/``max`` on the native date inputs — prevents the user from typing
// a date that would be silently reverted by the parent's
// ``isValueAllowed`` fallback (which would replace it with ``defaultPreset``
// and lose the custom year).
const footerMin = clampIsoDate(undefined, constraints, 'min');
const footerMax = clampIsoDate(undefined, constraints, 'max');
// Keyboard: Esc cancels, Enter applies
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const _onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
if (e.key === 'Enter') { e.preventDefault(); onApply(draft); }
};
window.addEventListener('keydown', _onKey);
return () => window.removeEventListener('keydown', _onKey);
}, [draft, onApply, onCancel]);
useLayoutEffect(() => {
const pop = popRef.current;
if (!pop) return;
const _clamp = () => {
const parent = pop.parentElement;
if (!parent) return;
const pRect = parent.getBoundingClientRect();
const margin = 8;
const popW = pop.offsetWidth || 720;
const popH = pop.offsetHeight || 400;
let left = pRect.left;
let top = pRect.bottom + 6;
if (left + popW > window.innerWidth - margin) {
left = window.innerWidth - margin - popW;
}
if (left < margin) left = margin;
if (top + popH > window.innerHeight - margin) {
top = Math.max(margin, pRect.top - 6 - popH);
}
pop.style.position = 'fixed';
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
pop.style.right = 'auto';
pop.style.zIndex = '2001';
};
_clamp();
const id = requestAnimationFrame(() => _clamp());
return () => cancelAnimationFrame(id);
}, []);
return (
<div ref={popRef} className={styles.popover}>
<div className={styles.body}>
{/* Column 1: Presets */}
<div className={styles.colPresets}>
{PRESETS_ORDER.map((kind) => {
const disabled = isPresetDisabled(kind, constraints);
const cls = [styles.presetBtn];
if (draft.preset.kind === kind) cls.push(styles.active);
return (
<button
key={kind}
type="button"
className={cls.join(' ')}
disabled={disabled}
onClick={() => _selectPreset(kind)}
>
{_presetLabel(kind, t)}
</button>
);
})}
</div>
{/* Column 2: Letzte/Nächste N */}
<div className={styles.colLastN}>
<h4 className={styles.colTitle}>{t('Letzte oder Nächste N')}</h4>
<div className={styles.lastNRow}>
<div className={styles.seg}>
<button
type="button"
className={`${styles.segBtn} ${lastNDirection === 'last' ? styles.on : ''}`}
disabled={isPresetDisabled('lastN', constraints)}
onClick={() => setLastNDirection('last')}
>
{t('Letzte')}
</button>
<button
type="button"
className={`${styles.segBtn} ${lastNDirection === 'next' ? styles.on : ''}`}
disabled={isPresetDisabled('nextN', constraints)}
onClick={() => setLastNDirection('next')}
>
{t('Nächste')}
</button>
</div>
</div>
<div className={styles.lastNRow}>
<input
type="number"
min={1}
className={styles.numInput}
value={lastNAmount}
onChange={(e) => setLastNAmount(parseInt(e.target.value, 10) || 1)}
/>
<select
className={styles.unitSelect}
value={lastNUnit}
onChange={(e) => setLastNUnit(e.target.value as PeriodUnit)}
>
<option value="day">{_unitLabel('day', t)}</option>
<option value="week">{_unitLabel('week', t)}</option>
<option value="month">{_unitLabel('month', t)}</option>
<option value="year">{_unitLabel('year', t)}</option>
</select>
</div>
<button
type="button"
className={styles.applyN}
onClick={_applyLastN}
disabled={
(lastNDirection === 'last' && isPresetDisabled('lastN', constraints))
|| (lastNDirection === 'next' && isPresetDisabled('nextN', constraints))
}
>
{t('Übernehmen')}
</button>
</div>
{/* Column 3: Calendar */}
<PeriodPickerCalendar
anchor={calAnchor}
onAnchorChange={setCalAnchor}
range={rangePick}
onPickDate={_onPickDate}
constraints={constraints}
/>
</div>
{/* Footer */}
<div className={styles.footer}>
<span className={styles.footerLabel}>{t('Von')}</span>
<input
type="date"
className={styles.footerInput}
value={draft.preset.kind === 'allTime' ? '' : draft.fromDate}
min={footerMin}
max={footerMax}
disabled={draft.preset.kind === 'allTime'}
onChange={(e) => _onFooterFromChange(e.target.value)}
/>
<span className={styles.footerLabel}>{t('Bis')}</span>
<input
type="date"
className={styles.footerInput}
value={draft.preset.kind === 'allTime' ? '' : draft.toDate}
min={footerMin}
max={footerMax}
disabled={draft.preset.kind === 'allTime'}
onChange={(e) => _onFooterToChange(e.target.value)}
/>
<span className={styles.spacer} />
<button type="button" className={styles.btnGhost} onClick={onCancel}>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={() => onApply(draft)}
disabled={!draft.fromDate || !draft.toDate || draft.fromDate > draft.toDate}
>
{t('Übernehmen')}
</button>
</div>
</div>
);
};
export default PeriodPickerPopover;

View file

@ -0,0 +1,70 @@
/**
* PeriodPicker - shared type definitions.
*
* The component carries a *semantic* preset alongside the resolved date pair.
* Semantic presets (e.g. `ytd`, `last12Months`) are re-resolved on every render
* via `resolvePeriod` so dashboards stay fresh when revisited.
*/
export type PeriodUnit = 'day' | 'week' | 'month' | 'year';
export type PeriodPresetKind =
| 'allTime'
| 'ytd'
| 'lastYear'
| 'nextYear'
| 'last12Months'
| 'next12Months'
| 'thisMonth'
| 'lastMonth'
| 'thisQuarter'
| 'lastQuarter'
| 'lastN'
| 'nextN'
| 'custom';
export type PeriodPreset =
| { kind: 'allTime' }
| { kind: 'ytd' }
| { kind: 'lastYear' }
| { kind: 'nextYear' }
| { kind: 'last12Months' }
| { kind: 'next12Months' }
| { kind: 'thisMonth' }
| { kind: 'lastMonth' }
| { kind: 'thisQuarter' }
| { kind: 'lastQuarter' }
| { kind: 'lastN'; amount: number; unit: PeriodUnit }
| { kind: 'nextN'; amount: number; unit: PeriodUnit }
| { kind: 'custom' };
export interface PeriodValue {
preset: PeriodPreset;
/** ISO `YYYY-MM-DD` (no time, no timezone). */
fromDate: string;
/** ISO `YYYY-MM-DD` (no time, no timezone). */
toDate: string;
}
export type PeriodDirection = 'past' | 'future' | 'any';
export interface PeriodConstraints {
direction?: PeriodDirection;
/** ISO `YYYY-MM-DD`. */
minDate?: string;
/** ISO `YYYY-MM-DD`. */
maxDate?: string;
/** Whitelist of allowed preset kinds; if omitted, all are allowed. */
enabledPresets?: PeriodPresetKind[];
}
export interface PeriodPickerProps extends PeriodConstraints {
value: PeriodValue | null;
onChange: (next: PeriodValue) => void;
/** Used as initial value if `value` is null. */
defaultPreset?: PeriodPreset;
placeholder?: string;
disabled?: boolean;
/** Optional inline className for the trigger button wrapper. */
className?: string;
}

View file

@ -0,0 +1,21 @@
export { PeriodPicker, default } from './PeriodPicker';
export type {
PeriodDirection,
PeriodPickerProps,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
PeriodConstraints,
} from './PeriodPickerTypes';
export {
daysInRange,
formatIsoDateDe,
fromIsoDate,
isPresetDisabled,
isValueAllowed,
presetLiteralKey,
resolvePeriod,
toIsoDate,
todayDate,
} from './PeriodPickerLogic';

View file

@ -10,17 +10,21 @@ export interface Tab {
export interface TabsProps {
tabs: Tab[];
defaultTabId?: string;
/** Controlled active tab. When provided, internal state is ignored. */
activeTabId?: string;
onTabChange?: (tabId: string) => void;
className?: string;
}
export function Tabs({ tabs, defaultTabId, onTabChange, className = '' }: TabsProps) {
const [activeTabId, setActiveTabId] = useState<string>(
export function Tabs({ tabs, defaultTabId, activeTabId: controlledTabId, onTabChange, className = '' }: TabsProps) {
const [internalTabId, setInternalTabId] = useState<string>(
defaultTabId || tabs[0]?.id || ''
);
const activeTabId = controlledTabId ?? internalTabId;
const handleTabClick = (tabId: string) => {
setActiveTabId(tabId);
if (!controlledTabId) setInternalTabId(tabId);
onTabChange?.(tabId);
};

View file

@ -311,3 +311,28 @@
color: #f3f4f6;
}
}
/* Touch devices: always show action buttons */
@media (pointer: coarse) {
.chatActions {
display: flex;
}
}
/* Mobile portrait */
@media (max-width: 480px) {
.chatItem {
padding: 8px 8px;
font-size: 0.9rem;
}
.actionBtn {
padding: 4px 5px;
font-size: 0.85rem;
}
.search {
font-size: 0.9rem;
padding: 8px 10px;
}
}

View file

@ -93,3 +93,12 @@
border-top-color: var(--border-dark, #374151);
}
}
/* Mobile portrait */
@media (max-width: 480px) {
.legend {
gap: 8px;
font-size: 0.7rem;
padding: 6px 8px;
}
}

View file

@ -1,71 +1,49 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import React, { useCallback, useRef, useMemo, useState } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import FolderTree from '../../components/FolderTree/FolderTree';
import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useFileContext } from '../../contexts/FileContext';
import { useApiRequest } from '../../hooks/useApi';
import {
importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
} from '../../api/workflowApi';
import { useToast } from '../../contexts/ToastContext';
import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../FormGenerator/FormGeneratorTree';
import styles from './FilesTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface FilesTabProps {
context: UdbContext;
onFileSelect?: (fileId: string, fileName?: string) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'group'; name: string }>) => void;
/** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
* den Graph-Editor importiert wurde. */
onWorkflowImported?: (workflowId: string) => void;
}
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => {
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => {
const { t } = useLanguage();
const [searchQuery, setSearchQuery] = useState('');
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
folders,
refreshFolders,
treeFileNodes,
treeFilesLoading,
refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
handleDownloadFolder,
} = useFileContext();
const provider = useMemo(() => createFolderFileProvider(), []);
const [ownTreeKey, setOwnTreeKey] = useState(0);
const [sharedTreeKey, setSharedTreeKey] = useState(0);
const _folderNodes = useMemo(() => {
return folders.map(f => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0,
neutralize: f.neutralize ?? false,
scope: f.scope ?? 'personal',
}));
}, [folders]);
const _fileNodes: FileNode[] = useMemo(() => {
let result = treeFileNodes;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(f =>
f.fileName.toLowerCase().includes(q),
);
const _handleNodeClick = useCallback((node: TreeNode) => {
if (node.type === 'file') {
onFileSelect?.(node.id, node.name);
}
return result;
}, [treeFileNodes, searchQuery]);
}, [onFileSelect]);
const _refreshAll = useCallback(async () => {
await Promise.all([refreshTreeFiles(), refreshFolders()]);
}, [refreshTreeFiles, refreshFolders]);
const _handleRefresh = useCallback(() => {
setOwnTreeKey(k => k + 1);
setSharedTreeKey(k => k + 1);
}, []);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return;
@ -79,13 +57,13 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
headers: { 'Content-Type': 'multipart/form-data' },
});
}
await _refreshAll();
_handleRefresh();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
}, [context.instanceId, uploading, _refreshAll]);
}, [context.instanceId, uploading, _handleRefresh]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
@ -117,81 +95,36 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
}
}, [_uploadFiles]);
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
}, [handleMoveFile]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
}, [contextMoveFiles]);
const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
}, [handleDeleteFolder, selectedFolderId]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName });
await refreshTreeFiles();
}, [refreshTreeFiles]);
const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
}, [handleFileDelete]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds });
await Promise.all([refreshTreeFiles(), refreshFolders()]);
}, [refreshTreeFiles, refreshFolders]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
await Promise.all([refreshFolders(), refreshTreeFiles()]);
}, [refreshFolders, refreshTreeFiles]);
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
updateTreeFileNode(fileId, { scope: newScope });
/* Workflow import is only available when embedded in the graph editor */
const _handleWorkflowImport = useCallback(async (fileId: string, fileName: string) => {
if (context.surface !== 'graphEditor' || !context.instanceId) return;
if (!fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return;
try {
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
} catch (err) {
console.error('Failed to update scope:', err);
await refreshTreeFiles();
const result = await importWorkflowFromFile(request, context.instanceId, { fileId });
const warnings = result?.warnings ?? [];
const wfId = result?.workflow?.id;
if (warnings.length > 0) {
showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) }));
} else {
showSuccess(t('Workflow importiert (deaktiviert).'));
}
}, [updateTreeFileNode, refreshTreeFiles]);
if (wfId && onWorkflowImported) onWorkflowImported(wfId);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
showError(t('Import fehlgeschlagen: {msg}', { msg }));
}
}, [context.surface, context.instanceId, request, showSuccess, showError, t, onWorkflowImported]);
const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
updateTreeFileNode(fileId, { neutralize: newValue });
try {
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
} catch (err) {
console.error('Failed to toggle neutralize:', err);
await refreshTreeFiles();
const _handleNodeClickWithImport = useCallback((node: TreeNode) => {
_handleNodeClick(node);
if (node.type === 'file') {
_handleWorkflowImport(node.id, node.name);
}
}, [updateTreeFileNode, refreshTreeFiles]);
}, [_handleNodeClick, _handleWorkflowImport]);
const _onFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
try {
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to toggle folder neutralize:', err);
}
}, [refreshFolders, refreshTreeFiles]);
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
try {
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to change folder scope:', err);
}
}, [refreshFolders, refreshTreeFiles]);
if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>;
}
const _handleSendToChat = useCallback((node: TreeNode) => {
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
}, [onSendToChat]);
return (
<div
@ -201,13 +134,23 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
<div
style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #F25843', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#F25843',
}}>
pointerEvents: 'auto',
}}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }}
onDragLeave={(e) => {
if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}}
onDrop={_handleDrop}
>
{t('Dateien hier ablegen')}
</div>
)}
@ -224,8 +167,9 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
{uploading ? '...' : '+'}
</button>
<button
onClick={_refreshAll}
onClick={_handleRefresh}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title={t('Aktualisieren')}
>
{'\u21BB'}
</button>
@ -240,59 +184,33 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onChange={_handleFileInputChange}
/>
<input
type="text"
placeholder={t('Dateien suchen')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px',
}}
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<FormGeneratorTree
key={`own-${ownTreeKey}`}
provider={provider}
ownership="own"
title={t('Eigene')}
compact={true}
showFilter={true}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
<div style={{ flex: 1, overflow: 'auto' }}>
<FolderTree
folders={_folderNodes}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect ? (fileId: string) => {
const file = treeFileNodes.find(f => f.id === fileId);
onFileSelect(fileId, file?.fileName);
} : undefined}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle}
onFolderScopeChange={_onFolderScopeChange}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat}
<FormGeneratorTree
key={`shared-${sharedTreeKey}`}
provider={provider}
ownership="shared"
title={t('Geteilt mit mir')}
compact={true}
collapsible={true}
defaultCollapsed={true}
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? t('Keine Dateien gefunden') : t('Keine Dateien. Drag & Drop zum Hochladen.')}
</div>
)}
</div>
<div className={styles.legend}>
<span>{'\uD83D\uDC64'} {t('Persönlich')}</span>
<span>{'\uD83D\uDC64'} {t('Persoenlich')}</span>
<span>{'\uD83D\uDC65'} {t('Instanz')}</span>
<span>{'\uD83C\uDFE2'} {t('Mandant')}</span>
<span>{'\uD83D\uDD12'} {t('Neutralisiert')}</span>

File diff suppressed because it is too large Load diff

View file

@ -58,3 +58,19 @@
border-bottom-color: var(--accent, #818cf8);
}
}
/* Mobile portrait */
@media (max-width: 480px) {
.tabBar {
padding: 4px 4px 0;
}
.tab {
padding: 8px 6px;
font-size: 0.8rem;
}
.tabContent {
padding: 4px;
}
}

View file

@ -7,16 +7,28 @@ import styles from './UnifiedDataBar.module.css';
export type UdbTab = 'chats' | 'files' | 'sources';
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an Custom-Actions
* (z. B. `workflow.openInEditor`) weitergereicht, damit sie sich
* pro Surface registrieren können. */
export type UdbSurface =
| 'workspace'
| 'graphEditor'
| 'trustee'
| 'standalone'
| 'sharepoint';
export interface UdbContext {
instanceId: string;
mandateId?: string;
featureInstanceId?: string;
userId?: string;
/** Optionales Surface-Tag, hilft Custom-Actions zu entscheiden, wann sie sichtbar sind. */
surface?: UdbSurface;
}
export interface AddToChat_FileItem {
id: string;
type: 'file' | 'folder';
type: 'file' | 'group';
name: string;
}
@ -44,6 +56,9 @@ interface UnifiedDataBarProps {
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
onAttachDataSource?: (dsId: string) => void;
/** Wird aufgerufen, sobald aus der UDB-FilesTab ein Workflow-File in den
* Graph-Editor importiert wurde (Action `workflow.openInEditor`). */
onWorkflowImportedFromFile?: (workflowId: string) => void;
className?: string;
}
@ -72,6 +87,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onSendToChat_Files,
onSendToChat_FeatureSource,
onAttachDataSource,
onWorkflowImportedFromFile,
className,
}) => {
const { t } = useLanguage();
@ -116,6 +132,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
context={context}
onFileSelect={onFileSelect}
onSendToChat={onSendToChat_Files}
onWorkflowImported={onWorkflowImportedFromFile}
/>
)}
{currentTab === 'sources' && !hideTabs?.includes('sources') && (

View file

@ -142,6 +142,12 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.feature.workspace.dashboard': <FaPlay />,
'page.feature.workspace.editor': <FaPlay />,
'feature.workspace': <FaPlay />,
// Feature pages - Redmine
'feature.redmine': <FaClipboardList />,
'page.feature.redmine.stats': <FaChartBar />,
'page.feature.redmine.browser': <FaProjectDiagram />,
'page.feature.redmine.settings': <FaCog />,
};
// =============================================================================

View file

@ -1,61 +1,27 @@
import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import api from '../api';
import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles';
import React, { createContext, useContext } from 'react';
import { useFileOperations, useFolderOperations, type FilePreviewResult } from '../hooks/useFiles';
import type { FolderInfo } from '../api/fileApi';
import type { FileNode } from '../components/FolderTree/FolderTree';
export type { FolderInfo };
interface FileContextType {
folders: FolderInfo[];
foldersLoading: boolean;
refreshFolders: () => Promise<void>;
treeFileNodes: FileNode[];
treeFilesLoading: boolean;
loadTreeFiles: (folderId: string) => Promise<void>;
refreshTreeFiles: () => Promise<void>;
updateTreeFileNode: (fileId: string, patch: Partial<FileNode>) => void;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
handleDeleteFolder: (folderId: string) => Promise<void>;
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<FilePreviewResult>;
handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
uploadingFile: boolean;
deletingFiles: Set<string>;
previewingFiles: Set<string>;
downloadingFiles: Set<string>;
handleCreateFolder: (name: string, parentId?: string | null) => Promise<FolderInfo>;
handleRenameFolder: (folderId: string, name: string) => Promise<FolderInfo>;
handleDeleteFolderCascade: (folderId: string) => Promise<{ deletedFolders: number; deletedFiles: number }>;
handleMoveFolder: (folderId: string, parentId: string | null) => Promise<FolderInfo>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
fetchOwnFolderTree: () => Promise<FolderInfo[]>;
fetchSharedFolderTree: () => Promise<FolderInfo[]>;
}
export const FileContext = createContext<FileContextType | undefined>(undefined);
const _ROOT_KEY = '';
function _toFileNode(f: any): FileNode {
return {
id: f.id,
fileName: f.fileName || f.name || 'unknown',
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
scope: f.scope,
neutralize: f.neutralize,
sysCreatedBy: f.sysCreatedBy,
};
}
export function FileProvider({ children }: { children: React.ReactNode }) {
const {
handleFileUpload: hookHandleFileUpload,
@ -68,254 +34,21 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
downloadingFiles,
} = useFileOperations();
// ── Derive a session-scoped storage key from the current feature-instance URL ──
const location = useLocation();
const storageKey = useMemo(() => {
const match = location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
const instanceId = match ? match[3] : '_global';
return `folderTree-expandedIds-${instanceId}`;
}, [location.pathname]);
// ── Folder expanded state (persisted per feature-instance in sessionStorage) ──
const _loadExpanded = (key: string): Set<string> => {
try {
const stored = sessionStorage.getItem(key);
if (!stored) return new Set<string>();
const ids: string[] = JSON.parse(stored);
return new Set(ids.filter(id => id && id !== '__root__'));
} catch { return new Set<string>(); }
};
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => _loadExpanded(storageKey));
useEffect(() => {
setExpandedFolderIds(_loadExpanded(storageKey));
setTreeFilesMap(new Map());
setFolders([]);
}, [storageKey]);
// ── Folder state ──────────────────────────────────────────────────────
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [foldersLoading, setFoldersLoading] = useState(false);
const refreshFolders = useCallback(async () => {
setFoldersLoading(true);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
} finally {
setFoldersLoading(false);
}
}, []);
useEffect(() => { refreshFolders(); }, [refreshFolders, storageKey]);
// ── Tree files: lazy-loaded per expanded folder ───────────────────────
const [treeFilesMap, setTreeFilesMap] = useState<Map<string, FileNode[]>>(new Map());
const [treeFilesLoading, setTreeFilesLoading] = useState(false);
const loadTreeFiles = useCallback(async (folderId: string) => {
const key = folderId || _ROOT_KEY;
setTreeFilesLoading(true);
try {
const filterValue = folderId || null;
const resp = await api.get('/api/files/list', {
params: {
pagination: JSON.stringify({
page: 1,
pageSize: 500,
filters: { folderId: filterValue },
}),
},
});
const items: any[] = resp.data?.items || [];
setTreeFilesMap(prev => {
const next = new Map(prev);
next.set(key, items.map(_toFileNode));
return next;
});
} catch (err) {
console.error(`Failed to load tree files for folder ${folderId}:`, err);
} finally {
setTreeFilesLoading(false);
}
}, []);
const _removeTreeFiles = useCallback((folderId: string) => {
const key = folderId || _ROOT_KEY;
setTreeFilesMap(prev => {
const next = new Map(prev);
next.delete(key);
return next;
});
}, []);
const refreshTreeFiles = useCallback(async () => {
const keys = Array.from(treeFilesMap.keys());
if (!keys.includes(_ROOT_KEY)) keys.push(_ROOT_KEY);
await Promise.all(
keys.map(key => loadTreeFiles(key === _ROOT_KEY ? '' : key)),
);
}, [treeFilesMap, loadTreeFiles]);
const updateTreeFileNode = useCallback((fileId: string, patch: Partial<FileNode>) => {
setTreeFilesMap(prev => {
const next = new Map<string, FileNode[]>();
let found = false;
for (const [key, files] of prev) {
const updated = files.map(f => {
if (f.id === fileId) {
found = true;
return { ...f, ...patch };
}
return f;
});
next.set(key, updated);
}
return found ? next : prev;
});
}, []);
// Load root files on mount and on context change
useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles, storageKey]);
// Load files for expanded folders on mount and context change
useEffect(() => {
expandedFolderIds.forEach(id => {
if (!treeFilesMap.has(id)) {
loadTreeFiles(id);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const treeFileNodes: FileNode[] = useMemo(() => {
const result: FileNode[] = [];
for (const [, files] of treeFilesMap) {
for (const f of files) result.push(f);
}
return result;
}, [treeFilesMap]);
// ── Toggle expand: load/unload tree files ─────────────────────────────
const toggleFolderExpanded = useCallback((id: string) => {
setExpandedFolderIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
loadTreeFiles(id);
}
try { sessionStorage.setItem(storageKey, JSON.stringify([...next])); } catch {}
return next;
});
}, [storageKey, loadTreeFiles]);
// ── Folder operations ─────────────────────────────────────────────────
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
await api.post('/api/files/folders', { name, parentId: parentId || null });
await refreshFolders();
}, [refreshFolders]);
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
await api.put(`/api/files/folders/${folderId}`, { name: newName });
await refreshFolders();
}, [refreshFolders]);
const handleDeleteFolder = useCallback(async (folderId: string) => {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
_removeTreeFiles(folderId);
await refreshFolders();
}, [refreshFolders, _removeTreeFiles]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refreshFolders();
}, [refreshFolders]);
const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => {
await api.post('/api/files/batch-move', { folderIds, targetParentId });
await refreshFolders();
}, [refreshFolders]);
// ── File operations ───────────────────────────────────────────────────
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
await refreshTreeFiles();
await refreshFolders();
}, [refreshTreeFiles, refreshFolders]);
const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await api.post('/api/files/batch-move', { fileIds, targetFolderId });
await refreshTreeFiles();
await refreshFolders();
}, [refreshTreeFiles, refreshFolders]);
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
const result = await hookHandleFileUpload(file, workflowId);
if (result.success) {
await refreshTreeFiles();
await refreshFolders();
}
return result;
}, [hookHandleFileUpload, refreshTreeFiles, refreshFolders]);
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
const success = await hookHandleFileDelete(fileId, () => {
onOptimisticDelete?.();
});
if (success) {
await refreshTreeFiles();
await refreshFolders();
}
return success;
}, [hookHandleFileDelete, refreshTreeFiles, refreshFolders]);
const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => {
try {
const response = await api.get(`/api/files/folders/${folderId}/download`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${folderName}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download folder:', err);
}
}, []);
const {
handleCreateFolder,
handleRenameFolder,
handleMoveFolder,
handleDeleteFolderCascade,
handleMoveFiles,
fetchOwnFolderTree,
fetchSharedFolderTree,
} = useFolderOperations();
return (
<FileContext.Provider
value={{
folders,
foldersLoading,
refreshFolders,
treeFileNodes,
treeFilesLoading,
loadTreeFiles,
refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles,
handleDownloadFolder,
handleFileDelete,
handleFileUpload,
handleFileUpload: hookHandleFileUpload,
handleFileDelete: hookHandleFileDelete,
handleFilePreview,
handleFileDownload: async (fileId: string, fileName: string) => {
await handleFileDownload(fileId, fileName);
@ -324,6 +57,13 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
deletingFiles,
previewingFiles,
downloadingFiles,
handleCreateFolder,
handleRenameFolder,
handleMoveFolder,
handleDeleteFolderCascade,
handleMoveFiles,
fetchOwnFolderTree,
fetchSharedFolderTree,
}}
>
{children}

View file

@ -49,7 +49,7 @@ export function formatApiError(error: any, defaultMessage: string): string {
// Type for API request options
export interface ApiRequestOptions<T> {
url: string;
method: 'get' | 'post' | 'put' | 'delete';
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
data?: T;
params?: Record<string, string | number | boolean>;
additionalConfig?: Record<string, any>; // For responseType, headers, etc.
@ -74,7 +74,7 @@ export function useApiRequest<RequestData = any, ResponseData = any>() {
// Generate cache key for GET requests (only cache GET requests)
const cacheKey = method === 'get' ? generateCacheKey(url, method, params) : null;
// Mutating requests (POST/PUT/DELETE) invalidate the entire GET cache.
// Mutating requests (POST/PUT/PATCH/DELETE) invalidate the entire GET cache.
// This ensures refetch() after create/update/delete returns fresh data.
if (method !== 'get') {
requestCache.clear();

View file

@ -11,6 +11,7 @@ import {
fetchBalances,
fetchBalanceForMandate,
fetchTransactions,
fetchTransactionsPaginated,
fetchStatistics,
fetchAllowedProviders,
fetchSettingsAdmin,
@ -29,7 +30,11 @@ import {
type CreditAddRequest,
type CheckoutCreateRequest,
type MandateUserSummary,
type StatisticsRangeRequest,
type BillingBucketSize,
type BillingTransactionsPaginationParams,
} from '../api/billingApi';
import type { GroupLayout } from '../api/connectionApi';
// Re-export types
export type {
@ -41,9 +46,11 @@ export type {
AccountSummary,
CreditAddRequest,
MandateUserSummary,
StatisticsRangeRequest,
BillingBucketSize,
};
export type { TransactionType, ReferenceType } from '../api/billingApi';
export type { TransactionType, ReferenceType, BillingTransactionsPaginationParams } from '../api/billingApi';
/**
* Hook for user billing operations
@ -51,6 +58,17 @@ export type { TransactionType, ReferenceType } from '../api/billingApi';
export function useBilling() {
const [balances, setBalances] = useState<BillingBalance[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [transactionsPagination, setTransactionsPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
viewKey?: string;
displayName?: string;
} | null>(null);
const [statistics, setStatistics] = useState<UsageReport | null>(null);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
const { request, isLoading: loading, error } = useApiRequest();
@ -83,22 +101,41 @@ export function useBilling() {
try {
const data = await fetchTransactions(request, limit, offset);
setTransactions(Array.isArray(data) ? data : []);
setTransactionsPagination(null);
setTransactionsGroupLayout(null);
setTransactionsAppliedView(null);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
setTransactionsPagination(null);
setTransactionsGroupLayout(null);
setTransactionsAppliedView(null);
return [];
}
}, [request]);
// Fetch statistics
const loadStatistics = useCallback(async (
period: 'day' | 'month' | 'year',
year: number,
month?: number
) => {
const refetchTransactions = useCallback(async (params?: BillingTransactionsPaginationParams) => {
try {
const data = await fetchStatistics(request, period, year, month);
const data = await fetchTransactionsPaginated(request, params);
setTransactions(Array.isArray(data.items) ? data.items : []);
setTransactionsPagination(data.pagination ?? null);
setTransactionsGroupLayout(data.groupLayout ?? null);
setTransactionsAppliedView(data.appliedView ?? null);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
setTransactionsPagination(null);
setTransactionsGroupLayout(null);
setTransactionsAppliedView(null);
return null;
}
}, [request]);
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
try {
const data = await fetchStatistics(request, range);
setStatistics(data);
return data;
} catch (err) {
@ -130,6 +167,9 @@ export function useBilling() {
return {
balances,
transactions,
transactionsPagination,
transactionsGroupLayout,
transactionsAppliedView,
statistics,
allowedProviders,
loading,
@ -137,6 +177,7 @@ export function useBilling() {
loadBalances,
loadBalanceForMandate,
loadTransactions,
refetchTransactions,
loadStatistics,
loadAllowedProviders,
refetch: loadBalances,

View file

@ -66,12 +66,19 @@ export function useConfirm() {
return (
<div
onClick={_handleCancel}
// Backdrop intentionally has NO onClick handler: this confirm dialog
// must only close via the explicit Cancel/Confirm buttons or Escape.
// Accidental outside-clicks should NOT dismiss a decision the user
// hasn't made yet. (UX policy for all modal dialogs in PORTA.)
style={{
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onKeyDown={(e) => {
if (e.key === 'Escape') _handleCancel();
}}
tabIndex={-1}
>
<div
onClick={(e) => e.stopPropagation()}

View file

@ -12,11 +12,14 @@ import {
updateConnection as updateConnectionApi,
refreshMicrosoftToken as refreshMicrosoftTokenApi,
refreshGoogleToken as refreshGoogleTokenApi,
submitInfomaniakToken as submitInfomaniakTokenApi,
type Connection,
type AttributeDefinition,
type PaginationParams,
type CreateConnectionData,
type ConnectResponse
type ConnectResponse,
type PaginatedResponse,
type GroupLayout,
} from '../api/connectionApi';
// Re-export types for backward compatibility
@ -33,6 +36,8 @@ export function useConnections() {
totalItems: number;
totalPages: number;
} | null>(null);
const [groupLayout, setGroupLayout] = useState<GroupLayout | null>(null);
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const { request, isLoading, error } = useApiRequest<any, any>();
@ -88,6 +93,69 @@ export function useConnections() {
}
}, [checkPermission]);
const fetchGroupSectionSummaries = useCallback(
async (base: {
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: string }>;
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
}) => {
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
const { data } = await api.get('/api/connections/', {
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
});
return Array.isArray(data?.groups) ? data.groups : [];
},
[],
);
const refetchForSection = useCallback(
async (
paginationParams: any,
sectionFilter: Record<string, unknown>,
parentColumnFilters?: Record<string, unknown>,
) => {
const mergedFilters = {
...(parentColumnFilters || {}),
...(paginationParams.filters || {}),
...sectionFilter,
};
const pObj: Record<string, unknown> = {
page: paginationParams.page,
pageSize: paginationParams.pageSize,
filters: mergedFilters,
groupByLevels: [],
};
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
const { data } = await api.get('/api/connections/', {
params: { pagination: JSON.stringify(pObj) },
});
if (data && typeof data === 'object' && 'items' in data) {
return { items: data.items, pagination: data.pagination };
}
return { items: [], pagination: null };
},
[],
);
// Fetch connections with pagination support
const fetchConnections = useCallback(async (params?: PaginationParams): Promise<Connection[]> => {
try {
@ -100,11 +168,15 @@ export function useConnections() {
if (data.pagination) {
setPagination(data.pagination);
}
setGroupLayout((data as PaginatedResponse<Connection>).groupLayout ?? null);
setAppliedView((data as PaginatedResponse<Connection>).appliedView ?? null);
} else {
// Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : [];
setConnections(items);
setPagination(null);
setGroupLayout(null);
setAppliedView(null);
}
return Array.isArray(data) ? data : (data?.items || []);
@ -112,6 +184,8 @@ export function useConnections() {
console.error('Error fetching connections:', error);
setConnections([]);
setPagination(null);
setGroupLayout(null);
setAppliedView(null);
throw error;
}
}, [request]);
@ -138,10 +212,12 @@ export function useConnections() {
}
};
// Connect to a service (initiate OAuth)
const connectService = async (connectionId: string): Promise<ConnectResponse> => {
// Connect to a service (initiate OAuth). Pass reauth=true to force the
// provider's consent screen so newly added scopes (e.g. Calendar/Contacts)
// actually land on the access token instead of being silently skipped.
const connectService = async (connectionId: string, reauth: boolean = false): Promise<ConnectResponse> => {
try {
const data = await connectServiceApi(request, connectionId);
const data = await connectServiceApi(request, connectionId, reauth);
return data;
} catch (error) {
console.error('Error connecting service:', error);
@ -237,13 +313,13 @@ export function useConnections() {
};
// Connect with popup (OAuth flow)
const connectWithPopup = async (connectionId: string): Promise<void> => {
const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
setIsConnecting(true);
setConnectError(null);
try {
// Get the OAuth URL from backend
const response = await connectService(connectionId);
const response = await connectService(connectionId, reauth);
if (!response.authUrl) {
throw new Error('No OAuth URL received from backend');
}
@ -495,6 +571,26 @@ export function useConnections() {
}
};
// Infomaniak uses Personal Access Tokens (no OAuth). Two-step flow:
// 1. createInfomaniakConnection() - creates a PENDING UserConnection row
// 2. submitInfomaniakToken(connectionId, pat) - validates the PAT against
// /1/profile, persists it as the connection's bearer token, and flips
// the row to ACTIVE.
const createInfomaniakConnection = async (): Promise<Connection> => {
return await createConnection({
type: 'infomaniak',
authority: 'infomaniak',
});
};
const submitInfomaniakToken = async (
connectionId: string,
token: string
): Promise<void> => {
await submitInfomaniakTokenApi(request, connectionId, token);
await fetchConnections();
};
// Create Microsoft connection and open OAuth popup
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
if (isConnecting) return;
@ -685,6 +781,90 @@ export function useConnections() {
}
}, [connections, request]);
/**
* Generic wizard entry-point: create a connection of any supported type with
* optional knowledge consent + preferences, then immediately open the OAuth
* popup. The three individual `create*ConnectionAndAuth` methods are preserved
* for backward-compat but new wizard code should call this.
*/
const createConnectionAndAuth = async (
type: 'google' | 'msft' | 'clickup',
knowledgeIngestionEnabled: boolean,
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
): Promise<void> => {
if (isConnecting) return;
setIsConnecting(true);
try {
const newConnection = await createConnection({
type,
authority: type,
knowledgeIngestionEnabled,
knowledgePreferences: knowledgePreferences ?? null,
});
const connectResponse = await connectServiceApi(request, newConnection.id);
if (!connectResponse.authUrl) {
throw new Error('No OAuth URL received from backend');
}
const apiBaseUrl = getApiBaseUrl();
let authUrl = connectResponse.authUrl;
if (authUrl.startsWith('/')) authUrl = `${apiBaseUrl}${authUrl}`;
return await new Promise<void>((resolve, reject) => {
const popup = window.open(authUrl, `${type}-wizard`, 'width=500,height=600,scrollbars=yes,resizable=yes');
if (!popup) {
setIsConnecting(false);
reject(new Error('Popup was blocked. Please allow popups and try again.'));
return;
}
const SUCCESS_TYPES = new Set([
'google_connection_success', 'msft_connection_success', 'clickup_connection_success',
'google_auth_success',
]);
const ERROR_TYPES = new Set([
'google_connection_error', 'msft_connection_error', 'clickup_connection_error',
]);
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
setIsConnecting(false);
fetchConnections();
resolve();
}
}, 1000);
const messageListener = (event: MessageEvent) => {
const apiUrl = new URL(apiBaseUrl);
if (event.origin !== apiUrl.origin) return;
if (SUCCESS_TYPES.has(event.data.type)) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
fetchConnections();
resolve();
} else if (ERROR_TYPES.has(event.data.type)) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
reject(new Error(event.data.error || `${type} connection failed`));
}
};
window.addEventListener('message', messageListener);
});
} catch (error: any) {
setIsConnecting(false);
throw error;
}
};
return {
connections,
data: connections, // Alias for FormGenerator compatibility
@ -701,6 +881,9 @@ export function useConnections() {
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
createConnectionAndAuth,
isLoading,
loading: isLoading, // Alias for FormGenerator compatibility
isConnecting,
@ -709,6 +892,8 @@ export function useConnections() {
attributes,
permissions,
pagination,
groupLayout,
appliedView,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
fetchAttributes,
@ -716,7 +901,9 @@ export function useConnections() {
// Additional methods for FormGenerator
updateOptimistically,
handleInlineUpdate,
fetchConnectionById
fetchConnectionById,
fetchGroupSectionSummaries,
refetchForSection,
};
}
@ -726,13 +913,13 @@ export function useOAuthConnect() {
const [isConnecting, setIsConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const connectWithPopup = async (connectionId: string): Promise<void> => {
const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
setIsConnecting(true);
setConnectError(null);
try {
// Get the OAuth URL from backend
const response = await connectService(connectionId);
const response = await connectService(connectionId, reauth);
if (!response.authUrl) {
throw new Error('No OAuth URL received from backend');
}

View file

@ -12,6 +12,14 @@ import {
updateFile as updateFileApi,
deleteFile as deleteFileApi,
deleteFiles as deleteFilesApi,
getFolderTree,
createFolder as createFolderApi,
renameFolder as renameFolderApi,
moveFolder as moveFolderApi,
deleteFolderCascade as deleteFolderCascadeApi,
patchFolderScope as patchFolderScopeApi,
patchFolderNeutralize as patchFolderNeutralizeApi,
moveFiles as moveFilesApi,
type FolderInfo,
} from '../api/fileApi';
@ -60,6 +68,7 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
}
// Files list hook
@ -73,6 +82,8 @@ export function useUserFiles() {
totalItems: number;
totalPages: number;
} | null>(null);
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
const { checkPermission } = usePermissions();
@ -130,6 +141,69 @@ export function useUserFiles() {
}
}, [checkPermission]);
const fetchGroupSectionSummaries = useCallback(
async (base: {
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: string }>;
viewKey?: string | null;
groupField: string;
groupDirection?: 'asc' | 'desc';
}) => {
const pObj: Record<string, unknown> = {
page: 1,
pageSize: 25,
groupByLevels: [
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
};
if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
const { data } = await api.get('/api/files/list', {
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
});
return Array.isArray(data?.groups) ? data.groups : [];
},
[],
);
const refetchForSection = useCallback(
async (
paginationParams: PaginationParams & { page: number; pageSize: number },
sectionFilter: Record<string, unknown>,
parentColumnFilters?: Record<string, unknown>,
) => {
const mergedFilters = {
...(parentColumnFilters || {}),
...(paginationParams.filters || {}),
...sectionFilter,
};
const pObj: Record<string, unknown> = {
page: paginationParams.page,
pageSize: paginationParams.pageSize,
filters: mergedFilters,
groupByLevels: [],
};
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
const { data } = await api.get('/api/files/list', {
params: { pagination: JSON.stringify(pObj) },
});
if (data && typeof data === 'object' && 'items' in data) {
return { items: data.items, pagination: data.pagination };
}
return { items: [], pagination: null };
},
[],
);
const fetchFiles = useCallback(async (params?: PaginationParams) => {
// Check if user is authenticated before fetching files
const cachedUser = getUserDataCache();
@ -172,25 +246,20 @@ export function useUserFiles() {
if (data.pagination) {
setPagination(data.pagination);
}
setGroupLayout((data as any).groupLayout ?? null);
setAppliedView((data as any).appliedView ?? null);
} else {
// Handle non-paginated response (backward compatibility)
console.log('📋 Processing non-paginated response:', {
isArray: Array.isArray(data),
dataLength: Array.isArray(data) ? data.length : 'not an array',
firstItemRaw: Array.isArray(data) && data.length > 0 ? data[0] : null,
allDataRaw: data
});
// Use backend data directly - no mapping needed, just like prompts
const items = Array.isArray(data) ? data : [];
console.log('📊 Final files array (non-paginated, using backend data directly):', items);
setFiles(items);
setPagination(null);
setGroupLayout(null);
setAppliedView(null);
}
} catch (error: any) {
// Error is already handled by useApiRequest
setFiles([]);
setPagination(null);
setGroupLayout(null);
setAppliedView(null);
}
}, [request]);
@ -325,9 +394,13 @@ export function useUserFiles() {
attributes,
permissions,
pagination,
groupLayout,
appliedView,
fetchFileById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
ensureAttributesLoaded,
fetchGroupSectionSummaries,
refetchForSection,
};
}
@ -493,7 +566,6 @@ export function useFileOperations() {
file: globalThis.File,
workflowId?: string,
featureInstanceId?: string,
folderId?: string | null,
) => {
setUploadError(null);
setUploadingFile(true);
@ -518,9 +590,6 @@ export function useFileOperations() {
if (featureInstanceId) {
formData.append('featureInstanceId', featureInstanceId);
}
if (folderId) {
formData.append('folderId', folderId);
}
// FormData is now correctly configured for backend
@ -698,85 +767,170 @@ export function useFileOperations() {
};
}
// ── Folder management hook ──────────────────────────────────────────────────
// Folder operations hook
export function useFolderOperations() {
const [folderLoading, setFolderLoading] = useState(false);
const [folderError, setFolderError] = useState<string | null>(null);
const { request } = useApiRequest();
export function useFolders() {
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [loading, setLoading] = useState(false);
const { showError } = useToast();
const refresh = useCallback(async () => {
setLoading(true);
const fetchOwnFolderTree = useCallback(async (): Promise<FolderInfo[]> => {
setFolderLoading(true);
setFolderError(null);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
return await getFolderTree(request, 'me');
} catch (err: any) {
const msg = err?.message ?? 'Failed to fetch own folder tree';
setFolderError(msg);
throw err;
} finally {
setLoading(false);
setFolderLoading(false);
}
}, []);
}, [request]);
useEffect(() => { refresh(); }, [refresh]);
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
const fetchSharedFolderTree = useCallback(async (): Promise<FolderInfo[]> => {
setFolderLoading(true);
setFolderError(null);
try {
await api.post('/api/files/folders', { name, parentId: parentId || null });
await refresh();
return await getFolderTree(request, 'shared');
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Folder creation failed');
const msg = err?.message ?? 'Failed to fetch shared folder tree';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [refresh, showError]);
}, [request]);
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
const handleCreateFolder = useCallback(async (
name: string,
parentId?: string | null,
): Promise<FolderInfo> => {
setFolderLoading(true);
setFolderError(null);
try {
await api.put(`/api/files/folders/${folderId}`, { name: newName });
await refresh();
return await createFolderApi(request, name, parentId);
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Rename failed');
const msg = err?.message ?? 'Failed to create folder';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [refresh, showError]);
}, [request]);
const handleDeleteFolder = useCallback(async (folderId: string) => {
const handleRenameFolder = useCallback(async (
folderId: string,
name: string,
): Promise<FolderInfo> => {
setFolderLoading(true);
setFolderError(null);
try {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
await refresh();
return await renameFolderApi(request, folderId, name);
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Delete failed');
const msg = err?.message ?? 'Failed to rename folder';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [refresh, showError]);
}, [request]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
const handleMoveFolder = useCallback(async (
folderId: string,
parentId: string | null,
): Promise<FolderInfo> => {
setFolderLoading(true);
setFolderError(null);
try {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refresh();
return await moveFolderApi(request, folderId, parentId);
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
const msg = err?.message ?? 'Failed to move folder';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [refresh, showError]);
}, [request]);
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
const handleDeleteFolderCascade = useCallback(async (
folderId: string,
): Promise<{ deletedFolders: number; deletedFiles: number }> => {
setFolderLoading(true);
setFolderError(null);
try {
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
return await deleteFolderCascadeApi(request, folderId);
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
const msg = err?.message ?? 'Failed to delete folder';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [showError]);
}, [request]);
const handlePatchFolderScope = useCallback(async (
folderId: string,
scope: string,
cascadeToFiles: boolean = false,
): Promise<{ folderId: string; scope: string; filesUpdated: number }> => {
setFolderLoading(true);
setFolderError(null);
try {
return await patchFolderScopeApi(request, folderId, scope, cascadeToFiles);
} catch (err: any) {
const msg = err?.message ?? 'Failed to patch folder scope';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [request]);
const handlePatchFolderNeutralize = useCallback(async (
folderId: string,
neutralize: boolean,
): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> => {
setFolderLoading(true);
setFolderError(null);
try {
return await patchFolderNeutralizeApi(request, folderId, neutralize);
} catch (err: any) {
const msg = err?.message ?? 'Failed to patch folder neutralize';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [request]);
const handleMoveFiles = useCallback(async (
fileIds: string[],
targetFolderId: string | null,
): Promise<void> => {
setFolderLoading(true);
setFolderError(null);
try {
await moveFilesApi(request, fileIds, targetFolderId);
} catch (err: any) {
const msg = err?.message ?? 'Failed to move files';
setFolderError(msg);
throw err;
} finally {
setFolderLoading(false);
}
}, [request]);
return {
folders,
loading,
refresh,
folderLoading,
folderError,
fetchOwnFolderTree,
fetchSharedFolderTree,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFile,
handleDeleteFolderCascade,
handlePatchFolderScope,
handlePatchFolderNeutralize,
handleMoveFiles,
};
}

View file

@ -32,8 +32,8 @@ export interface Invitation {
roleIds: string[];
targetUsername: string;
email?: string;
createdBy: string;
createdAt: number;
sysCreatedBy: string;
sysCreatedAt: number;
expiresAt: number;
usedBy?: string;
usedAt?: number;
@ -41,9 +41,11 @@ export interface Invitation {
maxUses: number;
currentUses: number;
inviteUrl: string;
emailSent?: boolean;
isExpired?: boolean;
isUsedUp?: boolean;
// Backend-driven flags (computed @ Pydantic model + view enrichment)
emailSentFlag?: boolean;
emailSentAt?: number;
expiredFlag?: boolean;
usedUpFlag?: boolean;
}
export interface InvitationCreate {

View file

@ -24,6 +24,8 @@ import {
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
import { validateMandateName } from '../utils/mandateNameUtils';
import { resolveColumnTypes } from '../utils/columnTypeResolver';
import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
// Re-export types
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
@ -153,20 +155,21 @@ export function useAdminMandates() {
return await fetchMandateByIdApi(request, mandateId);
}, [request]);
// Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
const columns = attributes.map(attr => ({
// Generate columns from attributes (types merged via resolveColumnTypes)
const columns: ColumnConfig[] = useMemo(() => {
const raw = attributes.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource, // API endpoint for FK data
fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display
displayField: (attr as any).displayField,
}));
return resolveColumnTypes(raw, attributes);
}, [attributes]);
// Create mandate
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {

116
src/hooks/usePeriod.ts Normal file
View file

@ -0,0 +1,116 @@
/**
* usePeriod - state hook for the PeriodPicker.
*
* Persists the selection in the URL (`useSearchParams`) so navigation, page
* reloads and `PageManager` state preservation all keep the chosen range.
*
* URL keys (configurable via `paramKey` -> `${paramKey}Preset|From|To`):
* - `periodPreset` = preset.kind
* - `periodFrom` = ISO YYYY-MM-DD
* - `periodTo` = ISO YYYY-MM-DD
* - `periodAmount` / `periodUnit` (only for `lastN` / `nextN`)
*/
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
resolvePeriod,
type PeriodPreset,
type PeriodPresetKind,
type PeriodUnit,
type PeriodValue,
} from '../components/PeriodPicker';
const _PRESET_KINDS: PeriodPresetKind[] = [
'ytd', 'lastYear', 'nextYear', 'last12Months', 'next12Months',
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', 'lastN', 'nextN', 'custom',
];
const _UNITS: PeriodUnit[] = ['day', 'week', 'month', 'year'];
interface UsePeriodOptions {
/** URL prefix for params (default: "period"). Use a unique value per page if multiple pickers coexist. */
paramKey?: string;
/** Initial preset if URL is empty. Default: { kind: 'ytd' }. */
defaultPreset?: PeriodPreset;
}
interface UsePeriodReturn {
value: PeriodValue;
setValue: (next: PeriodValue) => void;
reset: () => void;
}
function _parsePresetFromParams(params: URLSearchParams, key: string): PeriodPreset | null {
const kind = params.get(`${key}Preset`) as PeriodPresetKind | null;
if (!kind || !_PRESET_KINDS.includes(kind)) return null;
if (kind === 'lastN' || kind === 'nextN') {
const amount = parseInt(params.get(`${key}Amount`) || '', 10);
const unit = params.get(`${key}Unit`) as PeriodUnit | null;
if (!amount || amount < 1 || !unit || !_UNITS.includes(unit)) return null;
return { kind, amount, unit };
}
return { kind } as PeriodPreset;
}
export function usePeriod(options: UsePeriodOptions = {}): UsePeriodReturn {
const paramKey = options.paramKey || 'period';
const defaultPreset: PeriodPreset = options.defaultPreset || { kind: 'ytd' };
const [searchParams, setSearchParams] = useSearchParams();
const value: PeriodValue = useMemo(() => {
const parsedPreset = _parsePresetFromParams(searchParams, paramKey);
if (parsedPreset) {
if (parsedPreset.kind === 'custom') {
const fromDate = searchParams.get(`${paramKey}From`) || '';
const toDate = searchParams.get(`${paramKey}To`) || '';
if (fromDate && toDate) return { preset: parsedPreset, fromDate, toDate };
} else {
const r = resolvePeriod(parsedPreset);
return { preset: parsedPreset, fromDate: r.fromDate, toDate: r.toDate };
}
}
const r = resolvePeriod(defaultPreset);
return { preset: defaultPreset, fromDate: r.fromDate, toDate: r.toDate };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, paramKey]);
const setValue = useCallback((next: PeriodValue) => {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set(`${paramKey}Preset`, next.preset.kind);
if (next.preset.kind === 'lastN' || next.preset.kind === 'nextN') {
params.set(`${paramKey}Amount`, String(next.preset.amount));
params.set(`${paramKey}Unit`, next.preset.unit);
params.delete(`${paramKey}From`);
params.delete(`${paramKey}To`);
} else if (next.preset.kind === 'custom') {
params.set(`${paramKey}From`, next.fromDate);
params.set(`${paramKey}To`, next.toDate);
params.delete(`${paramKey}Amount`);
params.delete(`${paramKey}Unit`);
} else {
params.delete(`${paramKey}From`);
params.delete(`${paramKey}To`);
params.delete(`${paramKey}Amount`);
params.delete(`${paramKey}Unit`);
}
return params;
}, { replace: true });
}, [setSearchParams, paramKey]);
const reset = useCallback(() => {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.delete(`${paramKey}Preset`);
params.delete(`${paramKey}From`);
params.delete(`${paramKey}To`);
params.delete(`${paramKey}Amount`);
params.delete(`${paramKey}Unit`);
return params;
}, { replace: true });
}, [setSearchParams, paramKey]);
return { value, setValue, reset };
}

View file

@ -73,12 +73,19 @@ export function usePrompt() {
return (
<div
onClick={_handleCancel}
// Backdrop intentionally has NO onClick handler: this dialog must only
// close via the explicit Cancel button, the Escape key on the input,
// or the Confirm button. Clicking outside the dialog should NOT
// dismiss the user's input. (UX policy for all modal forms in PORTA.)
style={{
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onKeyDown={(e) => {
if (e.key === 'Escape') _handleCancel();
}}
tabIndex={-1}
>
<div
onClick={(e) => e.stopPropagation()}

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