frontend_nyla/src/hooks/useWorkflows.ts
2026-03-17 22:51:36 +01:00

659 lines
23 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import {
deleteWorkflowApi,
deleteWorkflowsApi,
updateWorkflowApi,
fetchWorkflows as fetchWorkflowsApi,
fetchWorkflow as fetchWorkflowByIdApi,
fetchAttributes as fetchAttributesApi,
startWorkflowApi,
stopWorkflowApi,
deleteMessageApi,
deleteFileFromMessageApi,
type Workflow,
type AttributeDefinition,
type StartWorkflowRequest
} from '../api/workflowApi';
import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext';
import { usePermissions, type UserPermissions } from './usePermissions';
// Workflow interface matching backend
export interface UserWorkflow {
id: string;
mandateId: string;
status: string;
name?: string;
workflowMode?: string;
[key: string]: any; // Allow additional properties
}
// Re-export AttributeDefinition from workflowApi
export type { AttributeDefinition } from '../api/workflowApi';
// Attribute option interface (from backend)
export interface AttributeOption {
value: string | number;
label: string | { [key: string]: string }; // Can be string or object with language keys
}
// Pagination parameters
export interface PaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
}
/** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */
export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined {
if (!instanceId || !featureCode) return undefined;
if (featureCode === 'automation') return `/api/automations/${instanceId}`;
return undefined;
}
// Workflows list hook - pass instanceId and featureCode when in feature context for feature-scoped API
export function useUserWorkflows(options?: { instanceId?: string; featureCode?: string }) {
const apiBaseUrl = getWorkflowApiBaseUrl(options?.instanceId, options?.featureCode);
const [workflows, setWorkflows] = useState<UserWorkflow[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Workflow[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const attrs = await fetchAttributesApi(request, 'ChatWorkflow');
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, [request]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'ChatWorkflow');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => {
try {
const requestParams: any = {};
// Build pagination object if provided
if (params) {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
let data: any;
if (!apiBaseUrl) {
console.error('useUserWorkflows: apiBaseUrl is required (missing instanceId/featureCode)');
return;
}
const url = `${apiBaseUrl}/workflows`;
if (Object.keys(requestParams).length > 0) {
data = await request({ url, method: 'get', params: requestParams });
} else {
data = await fetchWorkflowsApi(request, undefined, apiBaseUrl);
}
// Handle paginated response
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
// Map API response to our frontend model
const mappedWorkflows = items.map((apiWorkflow: any): UserWorkflow => {
return {
id: apiWorkflow.id,
mandateId: apiWorkflow.mandateId || '',
status: apiWorkflow.status || 'unknown',
name: apiWorkflow.name,
workflowMode: apiWorkflow.workflowMode,
...apiWorkflow // Include any additional properties
};
});
setWorkflows(mappedWorkflows);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
// Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : [];
const mappedWorkflows = items.map((apiWorkflow: any): UserWorkflow => {
return {
id: apiWorkflow.id,
mandateId: apiWorkflow.mandateId || '',
status: apiWorkflow.status || 'unknown',
name: apiWorkflow.name,
workflowMode: apiWorkflow.workflowMode,
...apiWorkflow
};
});
setWorkflows(mappedWorkflows);
setPagination(null);
}
} catch (error: any) {
// Error is already handled by useApiRequest
setWorkflows([]);
setPagination(null);
}
}, [request, apiBaseUrl]);
// Optimistically remove a workflow from the local state
const removeOptimistically = (workflowId: string) => {
setWorkflows(prevWorkflows => prevWorkflows.filter(workflow => workflow.id !== workflowId));
};
// Optimistically update a workflow in the local state
const updateOptimistically = (workflowId: string, updateData: Partial<UserWorkflow>) => {
setWorkflows(prevWorkflows =>
prevWorkflows.map(workflow =>
workflow.id === workflowId
? { ...workflow, ...updateData }
: workflow
)
);
};
// Fetch a single workflow by ID
const fetchWorkflowById = useCallback(async (workflowId: string): Promise<UserWorkflow | null> => {
try {
const workflow = await fetchWorkflowByIdApi(request, workflowId, apiBaseUrl);
return workflow as UserWorkflow | null;
} catch (error: any) {
console.error('Error fetching workflow by ID:', error);
return null;
}
}, [request, apiBaseUrl]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string; // For options that need to be fetched (e.g., "user.role")
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
// Filter out non-editable fields based on readonly/editable flags
if (attr.readonly === true || attr.editable === false) {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
// Map backend attribute type to form field type
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
// Map backend types to form field types
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
} else if (attr.type === 'text') {
fieldType = (attr as any).multiline === true ? 'textarea' : 'string';
}
// Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union
// If needed, they should be handled via type casting: (attr as any).type === 'boolean'
// Define validators and required fields
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
if (attr.name === 'name') {
required = true;
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Workflow name cannot be empty';
}
if (typeof value === 'string' && value.length > 100) {
return 'Workflow name cannot exceed 100 characters';
}
return null;
};
} else if (fieldType === 'textarea') {
minRows = 4;
maxRows = 8;
}
// Multiselect validation
else if (fieldType === 'multiselect' && required) {
validator = (value: any[]) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return `${attr.label} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
minRows,
maxRows,
options,
optionsReference
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded - can be called by EditActionButton
const ensureAttributesLoaded = useCallback(async () => {
// If attributes are already loaded, return them
if (attributes && attributes.length > 0) {
return attributes;
}
// Otherwise, fetch them and return the result
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch attributes and permissions on mount
// Note: Do NOT fetch workflows here - let the table component control pagination
useEffect(() => {
fetchAttributes();
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
// Listen for workflow creation events to refetch workflows list
useEffect(() => {
const handleWorkflowCreated = (_event: CustomEvent<{ workflow: UserWorkflow }>) => {
// Refetch to ensure we have the latest data
fetchWorkflowsData();
};
window.addEventListener('workflowCreated', handleWorkflowCreated as EventListener);
return () => {
window.removeEventListener('workflowCreated', handleWorkflowCreated as EventListener);
};
}, [fetchWorkflowsData]);
return {
data: workflows,
loading,
error,
refetch: fetchWorkflowsData,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchWorkflowById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Workflow operations hook - pass instanceId and featureCode when in feature context for feature-scoped API
export function useWorkflowOperations(options?: { instanceId?: string; featureCode?: string }) {
const apiBaseUrl = getWorkflowApiBaseUrl(options?.instanceId, options?.featureCode);
const [startingWorkflow, setStartingWorkflow] = useState(false);
const [stoppingWorkflows, setStoppingWorkflows] = useState<Set<string>>(new Set());
const [deletingWorkflows, setDeletingWorkflows] = useState<Set<string>>(new Set());
const [editingWorkflows, setEditingWorkflows] = useState<Set<string>>(new Set());
const [deletingMessages, setDeletingMessages] = useState<Set<string>>(new Set());
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [startError, setStartError] = useState<string | null>(null);
const [stopError, setStopError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const [deleteMessageError, setDeleteMessageError] = useState<string | null>(null);
const [deleteFileError, setDeleteFileError] = useState<string | null>(null);
// Workflow selection context - to clear selection if deleted workflow is selected
const { selectedWorkflowId, clearWorkflow } = useWorkflowSelection();
const { request } = useApiRequest();
// Generic delete operation handler
const handleDeleteOperation = async <T>(
operationKey: string,
setLoadingSet: React.Dispatch<React.SetStateAction<Set<string>>>,
setErrorState: React.Dispatch<React.SetStateAction<string | null>>,
operation: () => Promise<T>,
errorMessages: { default: string; notFound: string; forbidden: string }
): Promise<{ success: boolean; error?: string }> => {
setErrorState(null);
setLoadingSet(prev => new Set(prev).add(operationKey));
try {
await operation();
return { success: true };
} catch (error: any) {
let errorMessage = error.message || errorMessages.default;
if (error.response?.status === 404) {
errorMessage = errorMessages.notFound;
return { success: true };
} else if (error.response?.status === 403) {
errorMessage = errorMessages.forbidden;
}
setErrorState(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoadingSet(prev => {
const newSet = new Set(prev);
newSet.delete(operationKey);
return newSet;
});
}
};
const startWorkflow = async (
instanceId: string,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
) => {
setStartError(null);
setStartingWorkflow(true);
try {
const response = await startWorkflowApi(request, instanceId, workflowData, options);
return { success: true, data: response };
} catch (error: any) {
const errorMessage = error.message || 'Failed to start workflow';
setStartError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setStartingWorkflow(false);
}
};
const stopWorkflow = async (instanceId: string, workflowId: string) => {
setStopError(null);
setStoppingWorkflows(prev => new Set(prev).add(workflowId));
try {
await stopWorkflowApi(request, instanceId, workflowId);
return { success: true };
} catch (error: any) {
const errorMessage = error.message || 'Failed to stop workflow';
setStopError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setStoppingWorkflows(prev => {
const newSet = new Set(prev);
newSet.delete(workflowId);
return newSet;
});
}
};
const handleWorkflowDelete = async (workflowId: string) => {
const result = await handleDeleteOperation(
workflowId,
setDeletingWorkflows,
setDeleteError,
() => deleteWorkflowApi(request, workflowId, apiBaseUrl),
{
default: 'Failed to delete workflow',
notFound: 'Workflow not found or has already been deleted.',
forbidden: 'No permission to delete this workflow.'
}
);
if (result.success) {
// Add a small delay to ensure backend has time to process
await new Promise(resolve => setTimeout(resolve, 300));
// Dispatch event to notify other components (e.g., dashboard dropdown)
window.dispatchEvent(new CustomEvent('workflowDeleted', {
detail: { workflowIds: [workflowId] }
}));
// Clear workflow selection if the deleted workflow was selected
if (selectedWorkflowId === workflowId) {
clearWorkflow();
}
}
return result.success;
};
const handleWorkflowDeleteMultiple = async (workflowIds: string[]) => {
setDeleteError(null);
setDeletingWorkflows(prev => {
const newSet = new Set(prev);
workflowIds.forEach(id => newSet.add(id));
return newSet;
});
try {
// Delete workflows one by one since there's no bulk delete endpoint
await deleteWorkflowsApi(request, workflowIds, apiBaseUrl);
// Add a small delay to ensure backend has time to process
await new Promise(resolve => setTimeout(resolve, 300));
// Dispatch event to notify other components (e.g., dashboard dropdown)
window.dispatchEvent(new CustomEvent('workflowDeleted', {
detail: { workflowIds }
}));
// Clear workflow selection if the selected workflow was deleted
if (selectedWorkflowId && workflowIds.includes(selectedWorkflowId)) {
clearWorkflow();
}
return true;
} catch (error: any) {
console.error(`❌ Bulk delete failed:`, error);
setDeleteError(error.message || 'Bulk delete failed');
return false;
} finally {
setDeletingWorkflows(prev => {
const newSet = new Set(prev);
workflowIds.forEach(id => newSet.delete(id));
return newSet;
});
}
};
const deleteMessage = async (workflowId: string, messageId: string) => {
const operationKey = `${workflowId}:${messageId}`;
return handleDeleteOperation(
operationKey,
setDeletingMessages,
setDeleteMessageError,
() => deleteMessageApi(request, workflowId, messageId, apiBaseUrl),
{
default: 'Failed to delete message',
notFound: 'Message not found or has already been deleted.',
forbidden: 'No permission to delete this message.'
}
);
};
const deleteFileFromMessage = async (
workflowId: string,
messageId: string,
fileId: string
) => {
const operationKey = `${workflowId}:${messageId}:${fileId}`;
return handleDeleteOperation(
operationKey,
setDeletingFiles,
setDeleteFileError,
() => deleteFileFromMessageApi(request, workflowId, messageId, fileId, apiBaseUrl),
{
default: 'Failed to delete file',
notFound: 'File not found or has already been deleted.',
forbidden: 'No permission to delete this file.'
}
);
};
const handleWorkflowUpdate = async (workflowId: string, updateData: Partial<{ name: string; description?: string; tags?: string[] }>, _originalWorkflowData?: any) => {
setUpdateError(null);
setEditingWorkflows(prev => new Set(prev).add(workflowId));
try {
const updatedWorkflow = await updateWorkflowApi(request, workflowId, updateData, apiBaseUrl);
return { success: true, workflowData: updatedWorkflow };
} catch (error: any) {
console.error(`Update failed for workflow ID ${workflowId}:`, error);
const errorMessage = error.response?.data?.message || error.message || 'Failed to update workflow';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
// Return detailed error information for proper handling
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
} finally {
setEditingWorkflows(prev => {
const newSet = new Set(prev);
newSet.delete(workflowId);
return newSet;
});
}
};
// Generic inline update handler for FormGeneratorTable
// Must merge changes with existing row data because backend requires full object
const handleInlineUpdate = async (workflowId: string, changes: Partial<UserWorkflow>, existingRow?: any) => {
if (!existingRow) {
throw new Error(`Existing row data required for inline update`);
}
// Merge changes with existing row data
const mergedData = {
name: existingRow.name,
...changes
};
const result = await handleWorkflowUpdate(workflowId, mergedData);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return {
// Loading states
startingWorkflow,
stoppingWorkflows,
deletingWorkflows,
editingWorkflows,
deletingMessages,
deletingFiles,
// Error states
startError,
stopError,
deleteError,
updateError,
deleteMessageError,
deleteFileError,
// Operations
startWorkflow,
stopWorkflow,
handleWorkflowDelete,
handleWorkflowDeleteMultiple,
handleWorkflowUpdate,
handleInlineUpdate,
deleteMessage,
deleteFileFromMessage
};
}