Compare commits
56 commits
main
...
feat/grafi
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d716bc205 | |||
| 5ff75a63e3 | |||
| 0941b9e0ad | |||
| 47b3c1ab23 | |||
| 25b56f585e | |||
| 930a34662d | |||
|
|
d42fa02736 | ||
|
|
dca587a2df | ||
|
|
79557e51ed | ||
| 3d580a5fca | |||
| 1d2d247273 | |||
|
|
992c0472c6 | ||
|
|
02bf4020d7 | ||
|
|
c7e94aea79 | ||
| 7c05cb0dd7 | |||
| e7a79a3484 | |||
|
|
ad96c6d861 | ||
|
|
70459d57e3 | ||
|
|
8cecf3b320 | ||
| aff9dcb7bd | |||
| 31586d62c1 | |||
| c8e9304801 | |||
| b61544d8b1 | |||
|
|
26958d1e16 | ||
|
|
28951a7d22 | ||
|
|
9e08953c44 | ||
|
|
a0c2323fe6 | ||
|
|
34d6c2b83d | ||
|
|
3f80d6d434 | ||
|
|
3016806db9 | ||
|
|
974c48e24d | ||
|
|
fe857d5ade | ||
|
|
a9e8e8cddd | ||
|
|
2994f3a090 | ||
|
|
f0e73b62d2 | ||
|
|
8679cdffcb | ||
|
|
d8ff3a84d9 | ||
|
|
c47dc67a84 | ||
|
|
e09ed758ff | ||
|
|
fc2cce8732 | ||
|
|
0270f59d44 | ||
|
|
208f7b63df | ||
|
|
1c2a196192 | ||
|
|
c702740714 | ||
|
|
0bdaf86153 | ||
|
|
ebaaef7b4e | ||
|
|
d771d4bc09 | ||
|
|
9093827e7c | ||
|
|
1c4233c7ea | ||
|
|
45ea3ed48b | ||
|
|
8e5a01df6d | ||
|
|
3f4a98381d | ||
|
|
b4574b6a2e | ||
|
|
46d6ad1dfa | ||
|
|
7d84160cdb | ||
|
|
629d26c404 |
181 changed files with 27728 additions and 6162 deletions
|
|
@ -23,6 +23,12 @@ export default tseslint.config(
|
|||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
1626
package-lock.json
generated
1626
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/App.tsx
10
src/App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
23
src/api.ts
23
src/api.ts
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
398
src/api/redmineApi.ts
Normal 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
59
src/api/tableViewApi.ts
Normal 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)}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
520
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal file
520
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal 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!]}
|
||||
{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;
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 .nodeConfigPanel’s 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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
99
src/components/FlowEditor/editor/CanvasHeader.test.tsx
Normal file
99
src/components/FlowEditor/editor/CanvasHeader.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
194
src/components/FlowEditor/nodes/shared/DataPicker.test.tsx
Normal file
194
src/components/FlowEditor/nodes/shared/DataPicker.test.tsx
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (1–4)', 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 (1–4)', 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 };
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
318
src/components/FlowEditor/nodes/shared/paramValidation.test.ts
Normal file
318
src/components/FlowEditor/nodes/shared/paramValidation.test.ts
Normal 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']]);
|
||||
});
|
||||
});
|
||||
216
src/components/FlowEditor/nodes/shared/paramValidation.ts
Normal file
216
src/components/FlowEditor/nodes/shared/paramValidation.ts
Normal 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-X→X "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);
|
||||
}
|
||||
55
src/components/FlowEditor/nodes/shared/scopeHelpers.ts
Normal file
55
src/components/FlowEditor/nodes/shared/scopeHelpers.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 '*';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
1084
src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx
Normal file
1084
src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
9
src/components/FormGenerator/FormGeneratorTree/index.ts
Normal file
9
src/components/FormGenerator/FormGeneratorTree/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { FormGeneratorTree } from './FormGeneratorTree';
|
||||
export type {
|
||||
TreeNode,
|
||||
TreeNodeProvider,
|
||||
TreeBatchAction,
|
||||
FormGeneratorTreeProps,
|
||||
Ownership,
|
||||
ScopeValue,
|
||||
} from './types';
|
||||
|
|
@ -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;
|
||||
87
src/components/FormGenerator/FormGeneratorTree/types.ts
Normal file
87
src/components/FormGenerator/FormGeneratorTree/types.ts
Normal 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;
|
||||
}
|
||||
339
src/components/FormGenerator/GroupingManager/GroupRow.module.css
Normal file
339
src/components/FormGenerator/GroupingManager/GroupRow.module.css
Normal 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);
|
||||
}
|
||||
379
src/components/FormGenerator/GroupingManager/GroupRow.tsx
Normal file
379
src/components/FormGenerator/GroupingManager/GroupRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal file
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
394
src/components/PeriodPicker/PeriodPicker.module.css
Normal file
394
src/components/PeriodPicker/PeriodPicker.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
182
src/components/PeriodPicker/PeriodPicker.tsx
Normal file
182
src/components/PeriodPicker/PeriodPicker.tsx
Normal 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;
|
||||
132
src/components/PeriodPicker/PeriodPickerCalendar.tsx
Normal file
132
src/components/PeriodPicker/PeriodPickerCalendar.tsx
Normal 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;
|
||||
266
src/components/PeriodPicker/PeriodPickerLogic.ts
Normal file
266
src/components/PeriodPicker/PeriodPickerLogic.ts
Normal 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);
|
||||
}
|
||||
363
src/components/PeriodPicker/PeriodPickerPopover.tsx
Normal file
363
src/components/PeriodPicker/PeriodPickerPopover.tsx
Normal 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;
|
||||
70
src/components/PeriodPicker/PeriodPickerTypes.ts
Normal file
70
src/components/PeriodPicker/PeriodPickerTypes.ts
Normal 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;
|
||||
}
|
||||
21
src/components/PeriodPicker/index.ts
Normal file
21
src/components/PeriodPicker/index.ts
Normal 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';
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') && (
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
116
src/hooks/usePeriod.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue